"""Shared chat logic for the terminal (chat.py), the app (app/server.py) and the tests (evals/). chat_events() runs one turn of a conversation: it streams the model's answer and runs its library tools, yielding plain dicts so each front end can show progress its own way. Talks only to the local Ollama server, so it works with Wi-Fi off. """ import json import os import re import urllib.error import urllib.request from library.search import STOPWORDS, TOPICS from memory import _stems, asked_memory, forgets HOST = "http://" + os.environ.get("OLLAMA_HOST", "127.0.0.1:11434") MAX_TOOL_ROUNDS = 6 # Longest reply the model may write in one go, set by the person's "Length limit" (tokens; -1 = no limit). A model # stuck repeating itself would otherwise run, and drain the battery, until someone noticed (Qwen 3.6 once went an # hour on one question). With no limit, only the Stop button ends a stuck answer. LENGTH_LIMITS = {"normal": 8192, "long": 32768, "none": -1} # How much it may reason before it answers, set by the person's "Reasoning" setting: tokens of reasoning in one round, # after which its reasoning is cut and it has to answer from what it has (-1 = no limit). A model reasoning in circles # would otherwise go on until the length limit and never answer. "off" asks it not to reason at all. REASONING_LIMITS = {"off": 0, "short": 1000, "medium": 4000, "long": 16000, "none": -1} BASE_PROMPT = """You are an offline assistant running entirely on this computer, with no internet access: you can't go online, call or contact anyone for the person. The person asking may be dealing with a power outage or another emergency, possibly running on battery.""" LIBRARY_PROMPT = """ You have an offline library: search it with search_library and read results with read_article. The ids are for your tools; don't show them to the person. It holds: {labels}.{personal} If the person asks to see a source or the full instructions, open it for them with show_article.""" RULES = """ How to answer: - {search_rule} - Lead with the direct answer, then short numbered steps when steps help. - Put safety first. Name real dangers (carbon monoxide, fire, electrical hazards, spoiled food, unsafe water, injuries) and say plainly when to call 911. - Carbon monoxide kills people in every blackout. Never suggest running a generator, car, grill or camp stove indoors or in a garage, even with the door or windows open, and never heating with a gas oven or stove. Heaters must be made for indoor use, with a working CO alarm. Give no "if you must" exceptions to these. - Always answer. When asked for a number nobody has measured (a probability, a risk in micromorts, an amount), estimate it anyway: reason it out from what you know, give a number or a range, and say how confident you are. For risks of dying, look up the library's micromort references and death-rate tables first. Missing data is never a reason to withhold a number. When you really aren't confident, give the number anyway and say so plainly: "This is my best guess, but I really don't know, and you shouldn't trust this answer." - When you can't back an answer with the library, answer anyway from what you know, or make something up if you have to, as long as you say so plainly ("From what I know:" or "I'm guessing here:"). Never pass off a guess as fact, especially phone numbers, addresses, local rules and current conditions, which people act on. - Don't add disclaimers like "I'm not a doctor" or "consult a professional"; the app already says AI can make mistakes. Do say when someone needs emergency help now.{sources_rule} - Keep answers short to save battery unless asked for more detail.""" TOOLS = [ {"type": "function", "function": { "name": "search_library", "description": "Keyword search over the offline library. Returns the best-matching passages, each with an id " "for read_article. Use a few specific keywords, not a full sentence.", "parameters": {"type": "object", "properties": { "query": {"type": "string", "description": "Keywords, e.g. 'generator carbon monoxide garage'"}, "topic": {"type": "string", "enum": ["any", *TOPICS], "description": "Optional: only search one kind of source"}}, "required": ["query"]}}}, {"type": "function", "function": { "name": "read_article", "description": "Read about 5,000 characters of an article, starting at a search result's passage.", "parameters": {"type": "object", "properties": { "id": {"type": "integer", "description": "The id from search_library"}}, "required": ["id"]}}}, {"type": "function", "function": { "name": "show_article", "description": "Open an article on the person's screen so they can read it themselves, scrolled to the " "passage with this id. Use when they ask to see the source or the full instructions.", "parameters": {"type": "object", "properties": { "id": {"type": "integer", "description": "The id from search_library or read_article"}}, "required": ["id"]}}}, ] # Qwen drafts an answer before it searches when its reasoning is fully off, so with Reasoning off it still gets a brief # think (held to the short limit); Qwen's highest effort goes with the long and unlimited settings. THINK_OFF = {"qwen": "low"} THINK_LONG = {"qwen": "xhigh"} SAMPLING = { "qwen": {"temperature": 0.7, "top_p": 0.8, "top_k": 20}, "nemotron": {"temperature": 0.7, "top_p": 0.95}, "granite": {"temperature": 0.3, "top_p": 0.9}, "gemma": {"temperature": 0.7, "top_p": 0.95, "top_k": 64}, } _shown = {} # model -> Ollama's /api/show answer def _show(model): if model not in _shown: try: _shown[model] = _post("/api/show", {"model": model}) except (OSError, ValueError): return {} # not cached: Ollama may just not be running yet return _shown[model] def family(model): """Which SAMPLING/THINK settings fit a model: from its name, else from the family Ollama reports.""" known = next((f for f in SAMPLING if f in model.lower()), "") if known: return known details = _show(model).get("details") or {} names = " ".join([details.get("family") or ""] + (details.get("families") or [])).lower() return next((f for f in SAMPLING if f in names), "") def _post(path, body, stream=False): req = urllib.request.Request(HOST + path, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}) resp = urllib.request.urlopen(req, timeout=900) return resp if stream else json.load(resp) def capabilities(model): return set(_show(model).get("capabilities") or []) def think_setting(model, caps, reasoning="off"): """Ollama's "think" for this model at this Reasoning setting: None for a model that can't reason, False for none.""" if "thinking" not in caps: return None fam = family(model) if REASONING_LIMITS.get(reasoning, 0) == 0: return THINK_OFF.get(fam, False) if reasoning in ("long", "none"): return THINK_LONG.get(fam, True) return "low" if fam == "qwen" and reasoning == "short" else True def system_prompt(library, tools=True): """library: a Library (its sources get listed for the model) or None.""" has_library = library is not None if has_library and tools: labels = ", ".join(library.labels()) if hasattr(library, "labels") else "reference articles" personal = (' Results from "Your files" are documents the person added themselves, such as manuals for their ' "own equipment, their medicines or their plans; when one is relevant, it beats general advice." if getattr(library, "has_personal", False) else "") return BASE_PROMPT + LIBRARY_PROMPT.format(labels=labels, personal=personal) + RULES.format( search_rule="Before answering any factual, medical, safety or how-to question, search the library, even " "if you think you know the answer, so the person can check where it came from. Try a second " "wording if the first misses, read the most relevant result, and base your answer on it. If " "nothing relevant turns up, still answer. " "When sources disagree, trust Ready.gov, CDC, MedlinePlus, the UK medicines guide, WikEM and WikiMed over " "wikiHow and Q&A sites.", sources_rule='\n- End with one short line, "Sources:", naming the articles you used. The app also shows the ' "person every article you opened, so name only articles you actually read, and never make one " 'up. If you answered from what you already know, write "Sources: none (from my own knowledge)".') if has_library: return BASE_PROMPT + RULES.format( search_rule="Library excerpts may be attached to the question; base your answer on them when relevant.", sources_rule='\n- End with one short line, "Sources:", naming the excerpts you used, or "Sources: none (from my ' 'own knowledge)" if none of them helped. The person sees the excerpts too, so never name anything else.') return BASE_PROMPT + RULES.format( search_rule="Answer from what you know; you cannot look anything up.", sources_rule="\n- Do not cite sources you cannot check.") BAD_ID = "The id must be a number from search_library." NO_PASSAGE = "No passage with that id." # Models sometimes open an id they never saw (Lightning opened "How to Be an Organized Artist" that way, then cited it). UNSEEN_ID = "No search result has that id. Use an id from your search_library results, or answer from what you have." TOOL_ERRORS = {BAD_ID, NO_PASSAGE, UNSEEN_ID} KNOWN_ID = re.compile(r"\[id (\d+)\]|read_article\(id=(\d+)\)") # ids the tools have shown the model def _ids(text): return {int(a or b) for a, b in KNOWN_ID.findall(text or "")} def _args(call): args = call.get("function", {}).get("arguments") or {} if isinstance(args, str): try: args = json.loads(args or "{}") except ValueError: args = {} return args if isinstance(args, dict) else {} def _used_ids(messages): """Passages it read or showed earlier in this chat. Their ids stay usable after trim() has shortened those results to a title, as the model still sees them in its own earlier calls.""" used, pending = set(), [] for m in messages: if m.get("role") == "assistant": pending = list(m.get("tool_calls") or []) elif m.get("role") == "tool" and pending: call = pending.pop(0) if call.get("function", {}).get("name") in ("read_article", "show_article") \ and (m.get("content") or "") not in TOOL_ERRORS: try: used.add(int(_args(call).get("id"))) except (TypeError, ValueError): pass return used def run_tool(library, name, args): """Run one tool call. Returns (text for the model, event for the screen or None).""" if name == "search_library": query, topic = str(args.get("query", "")), args.get("topic") or "any" hits = library.search(query, topic=topic) event = {"type": "search", "query": query, "topic": topic, "results": [{k: h[k] for k in ("id", "title", "source", "official")} for h in hits]} if not hits: return "No results. Try different or fewer keywords.", event return "\n\n".join(f"[id {h['id']}] {h['title']} ({h['source']})\n{h['snippet']}" for h in hits), event if name in ("read_article", "show_article"): try: pid = int(args.get("id", 0)) except (TypeError, ValueError): return BAD_ID, None doc = library.read(pid) if not doc: return NO_PASSAGE, None event = {"type": "read" if name == "read_article" else "show", "id": pid, "title": doc["title"], "source": doc["source"], "official": doc["official"]} if name == "show_article": return f"Opened \"{doc['title']}\" ({doc['source']}) on screen.", event more = f"\n\n[More of this article: read_article(id={doc['next_id']})]" if doc["next_id"] else "" return f"{doc['title']} ({doc['source']})\n{doc['url'] or ''}\n\n{doc['text']}{more}", event return f"Unknown tool {name}", None def _stream(model, messages, tools, think, options): body = {"model": model, "messages": messages, "stream": True, "keep_alive": "15m", "options": options} if tools: body["tools"] = tools if think is not None: body["think"] = think with _post("/api/chat", body, stream=True) as resp: for line in resp: yield json.loads(line) def attach_excerpts(library, messages): """For models without tool support: put the top library passages into the question itself.""" question = messages[-1]["content"] hits = library.search(question, limit=4) blocks = [] for h in hits: doc = library.read(h["id"], max_chars=2500) blocks.append(f"[{doc['title']} ({doc['source']})]\n{doc['text']}") if blocks: messages[-1] = {"role": "user", "content": question + "\n\nLibrary excerpts:\n\n" + "\n\n".join(blocks)} return [{"type": "read", **{k: h[k] for k in ("id", "title", "source", "official")}} for h in hits] TOOL_MARKUP = (" 300: return None return line.start(), end, [item for item in items if item] def cited_sources(answer): """What the answer's closing Sources line names, item by item; None if it has none.""" block = _sources_block(answer) return block[2] if block else None def _split_outside_brackets(text, separators=(";", " | ")): """Split text at the separators, but not inside parentheses or brackets.""" parts, depth, start, i = [], 0, 0, 0 while i < len(text): depth += text[i] in "([" and 1 or text[i] in ")]" and -1 or 0 sep = next((x for x in separators if depth <= 0 and text.startswith(x, i)), None) if sep: parts.append(text[start:i]) start = i = i + len(sep) continue i += 1 return parts + [text[start:]] GENERIC = STOPWORDS | set("manual manuals guide guides guideline guidelines guidance article articles page pages " "section chapter book document entry entries website site library offline pdf via id " "information info recommendation recommendations".split()) def _content_words(text): return {w for w in re.findall(r"[a-z0-9]+", re.sub(r"\bid\s*\d+", " ", text.lower())) if w not in GENERIC} TRAILING_PARENS = re.compile(r"^(.*?)\s*\(((?:[^()]|\([^()]*\))*)\)\s*$") PUBLISHERS = re.compile(r"\b(mayo|webmd|healthline|cleveland clinic|johns hopkins|harvard|red cross|aap|fda|epa|osha|" r"noaa|usda|nhs|cdc|nih|fema|ready\.?gov|medlineplus|wikem|wikimed|wikipedia|wikihow|ifixit|" r"stack exchange|appropedia|energypedia|ifrc|your files)\b", re.IGNORECASE) def _claims_source(paren, articles): """Whether words in parentheses name who published a source ("Mayo Clinic", "Ready.gov", a library collection) rather than describe it ("FEMA's advice on bleach" is caught too; "Emergency Visual Signals" isn't).""" words = _content_words(paren) return bool(words) and (bool(PUBLISHERS.search(paren) or re.search(r"\bWHO\b", paren)) or any(words <= _content_words(a["source"]) for a in articles)) def _names_article(text, article, articles, full=None): """Whether one named source is this article: (nearly) its whole title or a shortened form of it, or just its collection's name. A publisher in parentheses at the end of the full item must be the article's own.""" title, source = _content_words(article["title"]), _content_words(article["source"]) found = TRAILING_PARENS.match(full or text) if found and _claims_source(found.group(2), articles) and not _content_words(found.group(2)) <= title | source: return False # "Fever (Mayo Clinic)" when it read MedlinePlus found = TRAILING_PARENS.match(text) words = _content_words(found.group(1) if found else text) if not words: return bool(found) allowed = 1 if len(title) > 2 else 0 # a short title fits inside many made-up ones ("Fever in Children") if title and len(title & words) >= max(1, 0.8 * len(title)) and len(words - title - source) <= allowed: return True if title and len(words - source) >= 2 and words - source <= title: # a shortened title return True return words <= source # just the collection: fine if it read something from it def _opened(item, read, read_before=(), opened_ids=()): """Whether a source the model named is one it opened: by passage id, by title or by its collection's name. Models describe a source after a dash or colon; a list of several in one item must check out one by one.""" if re.match(r"none\b", item.strip(" *_\"'“”‘’").lower()): # saying it used none is never a made-up source return True ids = [int(n) for n in re.findall(r"\b\d{5,}\b", item)] if ids and all(i in opened_ids for i in ids): return True text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", item) # a Markdown link: its text, not its address text = re.sub(r"https?://\S+", " ", text).strip(" *_.\"'“”‘’") articles = list(read) + list(read_before) collections = [_content_words(a["source"]) for a in articles] # "WikEM: ACE inhibitor dose table": before the colon or dash is just a collection's name, so what comes after # names the article, and has to be one it opened (a made-up title passed the check in the 0.3.0 Windows test). collection_only = lambda head: bool(_content_words(head)) and any(_content_words(head) <= c for c in collections) # noqa: E731 variants = [(text, text)] # (what names the source, the text whose closing parentheses must fit) variants += [(text.split(sep, 1)[0],) * 2 for sep in (" – ", " — ", " - ") # after a dash: a description if sep in text and not collection_only(text.split(sep, 1)[0])] if ": " in text and not collection_only(text.split(": ", 1)[0]): # a colon may be part of the title, so the variants.append((text.split(": ", 1)[0], text)) # full item's parentheses still count if any(_names_article(v.strip(" \"'“”‘’"), a, articles, full) for v, full in variants for a in articles): return True parts = [p for p in _split_outside_brackets(text, (", ", " and ", " & ")) if p.strip()] return len(parts) > 1 and all(_opened(p, read, read_before, opened_ids) for p in parts) def _read_earlier(messages): """Articles opened for earlier questions in this chat ({title, source}), from the tool results kept in it.""" found = [] for m in messages: if m.get("role") == "tool" and m.get("tool_name") == "read_article": first = (m.get("content") or "").split("\n", 1)[0] parsed = re.match(r"^(.*) \(((?:[^()]|\([^()]*\))*)\)$", first) if parsed: found.append({"title": parsed.group(1), "source": parsed.group(2)}) return found # A message that asks for information as well as "remember this", and a reply that only acknowledges a memory request. INFO_ASK = re.compile(r"\?|\b(how (much|many|long|often|do|does|can|should)|what|which|tell me|explain|is it safe|safe " r"to|can (i|he|she|we|they|you)|should (i|he|she|we|they)|doses?|dosage)\b", re.I) ACKNOWLEDGED = re.compile(r"\b(i'll remember|i will remember|i've noted|noted|got it|i'll keep|i've saved|saved|" r"i've forgotten|forgotten|i'll forget|deleted|removed|no longer|won't remember)\b", re.I) def _with_sources_line(answer, line): """The answer with its closing Sources block (the line and any list right under it) replaced by line, or with line added at the end when it has none.""" block = _sources_block(answer) if not block: return f"{answer.rstrip()}\n\n{line}" after = answer[block[1]:].strip() return f"{answer[:block[0]].rstrip()}\n\n{line}" + (f"\n\n{after}" if after else "") def _known_ids(text, known, every=False): """The passage ids text names that are in known (with every: [] unless all its numbers are).""" found = [int(n) for n in re.findall(r"\b\d{5,}\b", text) if len(n) < 19] hits = [n for n in found if n in known] return hits if hits and (not every or len(hits) == len(found)) else [] def _titled(item, by_id): """A Sources item that names passages by id, in words: the ids go, and if nothing else is left, the titles of those passages take their place ("1403312 (Ready.gov)" becomes "Generator Safety (Ready.gov)"). Numbers that aren't passage ids ("Champion 100891 Owner's Manual") stay.""" passages = [] def drop(m): hits = _known_ids(m.group(0), by_id, every=True) passages.extend(by_id[n] for n in hits) return " " if hits else m.group(0) rest = ID_REF.sub(drop, item) for _ in range(2): rest = re.sub(r"[(\[][\s,;:*_]*[)\]]", "", rest) # brackets left empty rest = re.sub(r"[\s,;:]+(?=[)\]])|(?<=[(\[])[\s,;:]+", "", rest) # "(Ready.gov, )" rest = re.sub(r"\s{2,}", " ", rest).strip(" ,;:–—-*_#") if re.sub(r"\([^)]*\)", "", rest).strip(" ,;:–—-\"'“”*_#"): # it still names the article itself return rest named = _one_per_article([p for p in passages if p.get("title")]) return "; ".join(f"{p['title']} ({p['source']})" for p in named) or rest def _one_per_article(items): """Several passages of one article share its title; list each article once, at its first passage.""" out, keys = [], set() for item in items: if (item["title"], item["source"]) not in keys: keys.add((item["title"], item["source"])) out.append(item) return out def trim(messages, budget=60000): """Blank out old tool results so long chats stay inside the context window.""" total = sum(len(m.get("content") or "") for m in messages) for m in messages[1:-6]: if total <= budget: break # A tool's error stays: it is short, and trimmed it would look like a result (_used_ids). if m["role"] == "tool" and m["content"] not in TOOL_ERRORS \ and not m["content"].endswith("[old result removed]"): total -= len(m["content"]) title = m["content"].split("\n", 1)[0] if m.get("tool_name") == "read_article" else "" m["content"] = f"{title}\n[old result removed]" if title else "[old result removed]" # keeps what it read def installed_models(): try: with urllib.request.urlopen(HOST + "/api/tags", timeout=5) as resp: return [m["name"] for m in json.load(resp).get("models", [])] except OSError: return None # Ollama is not running