| |
| """FR-Start β Fahrenheit Research incorporation advisor CLI (fully local, no API). |
| |
| Runs on Ollama. Usage: |
| python fr_start.py interactive chat |
| python fr_start.py "question" one-shot answer |
| """ |
| import sys |
| from pathlib import Path |
|
|
| import ollama |
|
|
| ROOT = Path(__file__).parent |
| |
| MODEL = "gemma3:12b" |
| NUM_CTX = 32768 |
|
|
|
|
| |
| def build_system() -> str: |
| prompt = (ROOT / "system_prompt.md").read_text() |
| corpus = "\n\n---\n\n".join( |
| f.read_text() for f in sorted((ROOT / "corpus").glob("*.md")) |
| ) |
| |
| tail = ( |
| "# Final rule (absolute)\n" |
| "Decision procedure for every user message, in this order:\n" |
| "1. Does it involve companies, incorporation, entities, taxes or tax rates, " |
| "compliance, banking, payroll, founder visas, funding, grants, cross-border " |
| "structures, or exits β in or between the US, India, UAE, Singapore, or UK? " |
| "If YES: answer it from the corpus. This includes short factual questions like " |
| "'What is Singapore's corporate tax rate?' or 'Which Dubai zone for fintech?'. " |
| "NEVER give the refusal reply to these.\n" |
| "2. Only if the message is clearly unrelated (code, poems, trivia, math, health, " |
| "other countries, casual chat): give the standard FR-Start reply from the Scope " |
| "section, nothing else. The standard reply is always the ENTIRE response β " |
| "never append it before or after an answer, and never use it when you have " |
| "answered the question.\n" |
| "Formatting: simple markdown β bold key terms, '- ' lists, and small markdown " |
| "tables for comparisons and the Decision card." |
| ) |
| return f"{prompt}\n\n# Reference corpus\n\n{corpus}\n\n{tail}" |
|
|
|
|
| MARKER = "This is FR-Start" |
|
|
|
|
| def stream_reply(messages: list[dict]): |
| """Stream the model's reply; cut off a scope-refusal wrongly appended after a real answer. |
| |
| ponytail: 12B model sometimes tacks the refusal boilerplate onto valid answers β |
| enforcing in code beats another prompt nudge. Holds back a small tail so the |
| marker can't slip through split across chunks. |
| """ |
| pending = "" |
| hold = len(MARKER) + 8 |
| for chunk in ollama.chat(model=MODEL, messages=messages, stream=True, |
| options={"num_ctx": NUM_CTX, "temperature": 0}): |
| pending += chunk["message"]["content"] |
| i = pending.find(MARKER) |
| if i > 0 and pending[:i].strip(): |
| yield pending[:i].rstrip() |
| return |
| if i != 0 and len(pending) > hold: |
| yield pending[:-hold] |
| pending = pending[-hold:] |
| yield pending |
|
|
|
|
| def chat() -> None: |
| system = build_system() |
| messages: list[dict] = [{"role": "system", "content": system}] |
| one_shot = sys.argv[1] if len(sys.argv) > 1 else None |
|
|
| print(f"FR-Start (local: {MODEL}) β where should you incorporate? (US / India / UAE / Singapore / UK)") |
| print("Ctrl-C or 'quit' to exit. First answer is slow while the model loads.\n") |
|
|
| while True: |
| if one_shot: |
| user = one_shot |
| else: |
| try: |
| user = input("you> ").strip() |
| except (EOFError, KeyboardInterrupt): |
| print() |
| return |
| if not user or user.lower() in ("quit", "exit"): |
| return |
|
|
| messages.append({"role": "user", "content": user}) |
| print() |
| reply = "" |
| for piece in stream_reply(messages): |
| reply += piece |
| print(piece, end="", flush=True) |
| print("\n") |
| messages.append({"role": "assistant", "content": reply}) |
|
|
| if one_shot: |
| return |
|
|
|
|
| if __name__ == "__main__": |
| chat() |
|
|