commit 913edc09511d64cb9a27c2dacfd7e9ea866287de Author: drjones Date: Fri Sep 4 22:33:38 2026 -0700 Astraea: WA family-law multi-agent site (Flask + Ollama semantic RAG) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee18991 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +venv/ +*.pyc +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d7501d --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Astraea βš–οΈ + +A constellation of **Washington State family-law specialist agents**, backed by a local +Ollama semantic-RAG pipeline. Eight specialists, each grounded in its own cited corpus of +Washington law (RCW), answer questions with inline citations β€” never from thin air. + +> For educational purposes only β€” not legal advice. + +## The eight specialists + +| Agent | Domain | Primary source | +|---|---|---| +| 🧭 Navigator | Court process, residency, intake, routing | RCW 26.09 (overview) | +| βš–οΈ Divorce & Dissolution | No-fault dissolution, legal separation, the 90-day wait | RCW 26.09 | +| πŸ§’ Child Custody & Parenting | Parenting plans, best-interest factors, relocation | RCW 26.09 / 26.10 | +| πŸ’΅ Child Support | Income-shares model, the economic table | RCW 26.19 | +| πŸ•ŠοΈ Spousal Maintenance | Maintenance factors & duration | RCW 26.09.090 | +| 🏠 Property & Debt | Community vs. separate property, division | RCW 26.16 / 26.09.080 | +| πŸ›‘οΈ Protection Orders | DV, anti-harassment, ERPO | RCW 7.105 | +| 🀝 Mediation & ADR | Mandatory mediation, settlement, collaborative law | RCW 26.09.015 | + +## Architecture + +``` +Flask (gunicorn + nginx) Ollama host (nightmare, RTX 4080 SUPER) + app.py ── /api/chat ──────────► granite4.2 (RAG answer, think:false, num_ctx<=16K) + rag.py ── embed + retrieve ───► nomic-embed-text-v2-moe (semantic embeddings) + agents.py (8 specialist defs) + books/*.md (8 cited WA-law corpora, chunked -> embedded -> index.json) +``` + +The RAG engine chunks each book on heading + sentence boundaries, embeds every chunk with +`nomic-embed-text-v2-moe`, and retrieves the top-5 by cosine similarity. The answer model +(`granite4.2`) is an instruction-follower that answers strictly from the retrieved context +and says so when the answer isn't in the corpus. + +## Deploy (Proxmox LXC) + +```bash +# On CT 150 "astraea" (10.30.20.160) +/opt/astraea/venv/bin/gunicorn --workers 2 --threads 4 --bind 0.0.0.0:5000 --timeout 240 app:app +# systemd: astraea.service Β· nginx: /etc/nginx/sites-enabled/astraea (80 -> 5000) +``` + +Redeploy: `tar czf /tmp/a.tar.gz app.py rag.py agents.py templates/ && scp β†’ proxmox β†’ pct push 150 β†’ extract β†’ systemctl restart astraea`. + +Reindex (after editing a book): `curl -X POST http://:5000/api/reindex` (or restart the +service). The vector index is cached at `/opt/astraea/index.json` and rebuilt only when a +book changes. + +## API + +- `GET /api/agents` β€” list specialists +- `POST /api/chat` β€” `{agent_id, message, history?}` β†’ `{answer, citations, grounded, latency_ms}` +- `GET /api/health` β€” agent count + index status +- `POST /api/reindex` β€” rebuild the vector index + +## Public + +https://astraea.thetempleofdoom.com (Cloudflare fleet tunnel `1aeb1ac0` β†’ 10.30.20.160:80) diff --git a/agents.py b/agents.py new file mode 100644 index 0000000..990a340 --- /dev/null +++ b/agents.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""Astraea β€” Washington State family-law specialist agents. +Each agent scopes a domain and pulls from its own set of 'books' (markdown sources). +""" + +# Backend Ollama (nightmare, RTX 4080 SUPER). Renumbers after reboot β€” .29 as of Sept 2026. +OLLAMA_URL = "http://10.30.20.29:11434" +EMBED_MODEL = "nomic-embed-text-v2-moe:latest" # semantic embeddings (verified on nightmare) +RAG_MODEL = "granite4.2:latest" # grounded-answer RAG lane (num_ctx <= 16384) +GENERAL_MODEL = "qwen3.8fast:latest" # fallback / general reasoning + +BOOKS_DIR = "/opt/astraea/books" + +# Each agent: id, name, emoji, tagline, description, books (list of .md filenames), system prompt. +AGENTS = [ + { + "id": "navigator", + "name": "Navigator", + "emoji": "\U0001F9ED", + "tagline": "Where to start", + "description": "Point you at the right specialist and walk through the Washington family-law process, from residency to final decree.", + "books": ["overview-intake.md", "mediation-adr.md"], + "accent": "#7dd3fc", + "system": ( + "You are the Navigator, the intake specialist for Astraea, a Washington State " + "family-law assistant. Help a person understand the overall WA family-law process: " + "residency requirements, the steps of a dissolution (petition, service, the 90-day " + "waiting period, temporary orders, settlement, trial), court fees and fee waivers, " + "and whether they need a lawyer. Most importantly, identify which specialist they " + "should talk to next and tell them so." + ), + }, + { + "id": "divorce", + "name": "Divorce & Dissolution", + "emoji": "\u2696\uFE0F", + "tagline": "Ending a marriage", + "description": "No-fault dissolution, legal separation, the 90-day wait, grounds, residency, and the decree.", + "books": ["divorce-dissolution.md"], + "accent": "#a5b4fc", + "system": ( + "You are the Divorce & Dissolution specialist for Astraea, a Washington State " + "family-law assistant. Washington is a no-fault divorce state. Explain dissolution of " + "marriage and legal separation under RCW 26.09: residency requirements, the petition, " + "service, the 90-day waiting period, temporary orders, default, and the final decree. " + "Be clear about what 'no-fault' means and what the court can and cannot decide." + ), + }, + { + "id": "custody", + "name": "Child Custody & Parenting", + "emoji": "\U0001F9D2", + "tagline": "Parenting plans", + "description": "Residential schedules, best-interest factors, restrictions, relocation, and nonparental custody.", + "books": ["custody-parenting.md"], + "accent": "#86efac", + "system": ( + "You are the Child Custody & Parenting Plans specialist for Astraea, a Washington " + "State family-law assistant. Explain parenting plans and residential schedules under " + "RCW 26.09.184-.191: the best-interest-of-the-child factors, restrictions under " + "RCW 26.09.191 (domestic violence, abuse, etc.), decision-making, relocation, " + "and nonparental custody actions under RCW 26.10. Always center the child's safety " + "and best interests." + ), + }, + { + "id": "child-support", + "name": "Child Support", + "emoji": "\U0001F4B5", + "tagline": "The support schedule", + "description": "Income-shares calculation, the WA economic table, deviations, imputed income, and modification.", + "books": ["child-support.md"], + "accent": "#fcd34d", + "system": ( + "You are the Child Support specialist for Astraea, a Washington State family-law " + "assistant. Explain Washington's child support schedule under RCW 26.19: the " + "income-shares model, the economic table, how income is combined, standard " + "calculations and deviations, imputed income, healthcare and special expenses, and " + "how to modify an existing order. Give practical guidance, not a legal ruling." + ), + }, + { + "id": "maintenance", + "name": "Spousal Maintenance", + "emoji": "\U0001F54A\uFE0F", + "tagline": "Alimony in WA", + "description": "Maintenance factors, duration, and when spousal support is ordered or modified.", + "books": ["spousal-maintenance.md"], + "accent": "#f0abfc", + "system": ( + "You are the Spousal Maintenance (alimony) specialist for Astraea, a Washington State " + "family-law assistant. Explain maintenance under RCW 26.09.090: the statutory factors " + "(length of marriage, need, ability to pay, age/health, standard of living), how " + "duration is typically set, and modification or termination of maintenance awards. " + "Be honest that maintenance is fact-specific and discretionary." + ), + }, + { + "id": "property", + "name": "Property & Debt", + "emoji": "\U0001F3E0", + "tagline": "Community property", + "description": "Separate vs. community property, characterization, division, pensions, and marital debt.", + "books": ["property-debt.md"], + "accent": "#fdba74", + "system": ( + "You are the Property & Debt Division specialist for Astraea, a Washington State " + "family-law assistant. Washington is a community property state. Explain the " + "difference between separate and community property and debt (RCW 26.16), how assets " + "are characterized, the court's 'just and equitable' division standard (RCW 26.09.080), " + "pensions/retirement and QDROs, and how marital debt is allocated." + ), + }, + { + "id": "protection", + "name": "Protection Orders", + "emoji": "\U0001F6E1\uFE0F", + "tagline": "Safety orders", + "description": "Domestic violence, anti-harassment, sexual assault, and extreme-risk protection orders (RCW 7.105).", + "books": ["protection-orders.md"], + "accent": "#fca5a5", + "system": ( + "You are the Protection Orders & Safety specialist for Astraea, a Washington State " + "family-law assistant. Washington consolidated protection orders under RCW 7.105 " + "(effective July 1, 2022): domestic violence protection orders, civil anti-harassment, " + "sexual assault protection orders, stalking, and extreme risk protection orders. " + "Explain how to file, what each order does, and safety resources. If someone describes " + "immediate danger, tell them to call 911 and offer crisis resources." + ), + }, + { + "id": "mediation", + "name": "Mediation & ADR", + "emoji": "\U0001F91D", + "tagline": "Settle, don't fight", + "description": "Mandatory mediation, settlement conferences, arbitration, collaborative law, and guardians ad litem.", + "books": ["mediation-adr.md"], + "accent": "#67e8f9", + "system": ( + "You are the Mediation & Alternative Dispute Resolution specialist for Astraea, a " + "Washington State family-law assistant. Explain mandatory mediation in WA family law, " + "settlement conferences, arbitration, collaborative law, parenting-plan mediation, and " + "the role of guardians ad litem. Help people resolve disputes without a contested trial." + ), + }, +] + +AGENT_BY_ID = {a["id"]: a for a in AGENTS} + +DISCLAIMER = "Astraea provides general legal information for Washington State, not legal advice. For your specific situation, consult a licensed Washington attorney." diff --git a/app.py b/app.py new file mode 100644 index 0000000..0138b9d --- /dev/null +++ b/app.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +"""Astraea β€” a constellation of Washington State family-law specialist agents. + +Flask app + semantic RAG backend. LLM (granite4.2 RAG lane) and embeddings +(nomic-embed-text-v2-moe) run on the Ollama host (nightmare). +""" +import json +import re +import time +import urllib.request + +from flask import Flask, jsonify, render_template, request + +import rag +from agents import ( + AGENTS, AGENT_BY_ID, OLLAMA_URL, RAG_MODEL, GENERAL_MODEL, DISCLAIMER, +) + +app = Flask(__name__) + + +def _ollama_chat(model, messages, num_ctx=16384, num_predict=1024, temperature=0.1, timeout=180): + payload = { + "model": model, + "messages": messages, + "stream": False, + "think": False, + "options": { + "temperature": temperature, + "num_predict": num_predict, + "num_ctx": num_ctx, + }, + } + req = urllib.request.Request( + f"{OLLAMA_URL}/api/chat", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(req, timeout=timeout) as r: + d = json.loads(r.read().decode("utf-8")) + msg = d.get("message", {}) + content = msg.get("content") or msg.get("thinking") or "" + return content.strip() + + +def _strip_model_sources(text): + """Remove a model-written trailing 'Sources:'/'References:' block (defensive).""" + text = re.split(r"\n\s*(?:Sources|References|Citations)\s*:\s*\n", text, flags=re.I)[0] + text = re.split(r"\n\s*Sources?\s*$", text, flags=re.I)[0] + return text.strip() + + +def _build_answer(agent, message, history): + top = rag.retrieve(message, agent["id"], top_k=5) + if not top: + return { + "answer": "I couldn't find relevant Washington law on that in my reference " + "library yet. Try rephrasing, or ask the Navigator to point you to " + "the right specialist.", + "citations": [], + "grounded": False, + } + + context_blocks = [] + citations = [] + seen = set() + for i, (score, c) in enumerate(top, 1): + key = (c["source"], c["title"]) + if key in seen: + continue + seen.add(key) + context_blocks.append(f"[{i}] ({c['source']} β€” {c['title']})\n{c['text']}") + citations.append({ + "source": c["source"], + "title": c["title"], + "score": round(score, 3), + "snippet": c["text"][:260] + ("…" if len(c["text"]) > 260 else ""), + }) + + system = ( + f"{agent['system']}\n\n" + "Rules:\n" + "- Answer the user's question DIRECTLY and concisely. Begin your answer immediately β€” " + "do NOT restate the question, do NOT narrate your reasoning, and do NOT say what you " + "are about to do.\n" + "- Answer using ONLY the reference documents provided below.\n" + "- Cite the RCW section (or source) inline for every legal claim, e.g. (RCW 26.09.030).\n" + "- If the documents do not contain the answer, say so clearly and suggest which " + "specialist or official resource to consult.\n" + "- Be practical and plain-English, specific to Washington State.\n" + "- Do NOT write a 'Sources' list at the end; cite inline only." + ) + user = ( + "REFERENCE DOCUMENTS:\n\n" + + "\n\n".join(context_blocks) + + f"\n\nUSER QUESTION: {message}" + ) + + messages = [{"role": "system", "content": system}] + if history: + for turn in history[-6:]: + if turn.get("role") in ("user", "assistant"): + messages.append({"role": turn["role"], "content": turn["content"]}) + messages.append({"role": "user", "content": user}) + + try: + answer = _ollama_chat(RAG_MODEL, messages) + except Exception as e: + # Fall back to the general model if the RAG lane is unavailable. + try: + answer = _ollama_chat(GENERAL_MODEL, messages, num_ctx=16384) + except Exception: + return { + "answer": "The legal engine is temporarily unavailable. Please try again in a " + "moment. (Backend LLM could not be reached.)", + "citations": [], + "grounded": False, + } + + answer = _strip_model_sources(answer) + return { + "answer": answer, + "citations": citations, + "grounded": True, + "model": RAG_MODEL, + } + + +@app.route("/") +def index(): + agents_public = [ + {k: a[k] for k in ("id", "name", "emoji", "tagline", "description", "accent")} + for a in AGENTS + ] + return render_template("index.html", agents=agents_public, disclaimer=DISCLAIMER) + + +@app.route("/api/agents") +def api_agents(): + return jsonify([ + {k: a[k] for k in ("id", "name", "emoji", "tagline", "description", "accent")} + for a in AGENTS + ]) + + +@app.route("/api/chat", methods=["POST"]) +def api_chat(): + data = request.get_json(force=True, silent=True) or {} + agent_id = (data.get("agent_id") or "navigator").strip() + message = (data.get("message") or "").strip() + history = data.get("history") or [] + agent = AGENT_BY_ID.get(agent_id) + if not agent: + return jsonify({"error": "Unknown agent"}), 400 + if not message: + return jsonify({"error": "Message required"}), 400 + if len(message) > 4000: + message = message[:4000] + + t0 = time.time() + result = _build_answer(agent, message, history) + result["latency_ms"] = round((time.time() - t0) * 1000) + result["agent"] = agent_id + return jsonify(result) + + +@app.route("/api/health") +def health(): + return jsonify({"ok": True, "agent_count": len(AGENTS), "index": rag.index_status()}) + + +@app.route("/api/index/status") +def index_status(): + return jsonify(rag.index_status()) + + +@app.route("/api/reindex", methods=["POST"]) +def reindex(): + try: + idx = rag.build_index() + return jsonify({"ok": True, "chunks": len(idx)}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +# Kick off the background index build once at import (before serving). +rag.start_background_index() + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, threaded=True) diff --git a/astraea-nginx.conf b/astraea-nginx.conf new file mode 100644 index 0000000..a5a3f46 --- /dev/null +++ b/astraea-nginx.conf @@ -0,0 +1,14 @@ +server { + listen 80; + server_name _; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300; + proxy_send_timeout 300; + } +} diff --git a/astraea.service b/astraea.service new file mode 100644 index 0000000..da271f3 --- /dev/null +++ b/astraea.service @@ -0,0 +1,13 @@ +[Unit] +Description=Astraea β€” Washington family-law specialist agents (Flask + RAG) +After=network.target + +[Service] +WorkingDirectory=/opt/astraea +ExecStart=/opt/astraea/venv/bin/gunicorn --workers 2 --threads 4 --bind 0.0.0.0:5000 --timeout 240 app:app +Restart=always +RestartSec=3 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target diff --git a/rag.py b/rag.py new file mode 100644 index 0000000..ef03483 --- /dev/null +++ b/rag.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""Semantic RAG engine for Astraea β€” chunk the law 'books', embed, and retrieve. + +stdlib-only (urllib + json + math), no numpy needed. Embeddings and generation +run on the Ollama host (nightmare). The vector index is cached to disk and rebuilt +incrementally only when a book changes. +""" +import json +import math +import os +import re +import threading +import urllib.request + +from agents import OLLAMA_URL, EMBED_MODEL, BOOKS_DIR, AGENTS + +INDEX_PATH = "/opt/astraea/index.json" + + +def _post(url, payload, timeout=120): + """POST JSON to Ollama with a no-proxy opener (safe on LAN).""" + req = urllib.request.Request( + url, data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(req, timeout=timeout) as r: + return json.loads(r.read().decode("utf-8")) + + +def embed(text): + """Return a float vector for `text` via the Ollama embeddings endpoint.""" + d = _post(f"{OLLAMA_URL}/api/embeddings", {"model": EMBED_MODEL, "prompt": text}) + v = d.get("embedding") or d.get("embeddings") + if isinstance(v, list) and v and isinstance(v[0], list): + v = v[0] + if not v: + raise RuntimeError("empty embedding returned") + return v + + +def _cosine(a, b): + dot = sum(x * y for x, y in zip(a, b)) + na = math.sqrt(sum(x * x for x in a)) + nb = math.sqrt(sum(y * y for y in b)) + return dot / (na * nb) if na and nb else 0.0 + + +MAX_CHUNK = 1100 # chars β€” stay safely under the embedding model's context budget + + +def _split_long(text, limit): + """Split text into pieces <= limit chars, preferring sentence boundaries.""" + if len(text) <= limit: + return [text] if text.strip() else [] + parts = re.split(r"(?<=[.!?])\s+", text) + out = [] + cur = "" + for p in parts: + if cur and len(cur) + len(p) + 1 > limit: + out.append(cur) + cur = p + else: + cur = (cur + " " + p).strip() if cur else p + while len(cur) > limit: + out.append(cur[:limit]) + cur = cur[limit:] + if cur.strip(): + out.append(cur) + return out + + +def _chunk_markdown(text): + """Split markdown into self-contained (title, body) chunks, each <= MAX_CHUNK chars.""" + lines = text.splitlines() + chunks = [] + cur_title = None + cur_buf = [] + + def flush(): + nonlocal cur_title, cur_buf + if not cur_buf: + return + body = "\n".join(cur_buf).strip() + cur_buf = [] + if not body: + return + for piece in _split_long(body, MAX_CHUNK): + chunks.append((cur_title or "Section", piece)) + + for line in lines: + if re.match(r"^#{1,4}\s+", line): + flush() + cur_title = re.sub(r"^#{1,4}\s+", "", line).strip() + else: + cur_buf.append(line) + flush() + return chunks + + +def build_index(): + """Build (or load from cache) the vector index. Returns list of chunk dicts.""" + index = [] + book_files = [] + for a in AGENTS: + for b in a["books"]: + path = os.path.join(BOOKS_DIR, b) + if os.path.exists(path): + book_files.append((path, a["id"])) + + # Decide whether to reuse cache: index.json exists, embed model matches, and no book changed. + if os.path.exists(INDEX_PATH): + try: + with open(INDEX_PATH) as f: + cached = json.load(f) + if cached.get("embed_model") == EMBED_MODEL: + newest_book = max(os.path.getmtime(p) for p, _ in book_files) if book_files else 0 + if os.path.getmtime(INDEX_PATH) >= newest_book: + return cached["chunks"] + except Exception: + pass + + for path, agent_id in book_files: + try: + with open(path, encoding="utf-8") as f: + text = f.read() + except Exception: + continue + base = os.path.basename(path) + for title, body in _chunk_markdown(text): + # Embed the title + body together for best semantic match. + try: + vec = embed(f"{title}\n{body}") + except Exception: + continue # skip any chunk that fails to embed + index.append({ + "agent": agent_id, + "source": base, + "title": title, + "text": body, + "vector": vec, + }) + + if index: + try: + with open(INDEX_PATH, "w") as f: + json.dump({"embed_model": EMBED_MODEL, "chunks": index}, f) + except Exception: + pass + return index + + +def retrieve(query, agent_id, top_k=5): + """Return top-k relevant chunks for `agent_id` via cosine similarity.""" + qvec = embed(query) + scored = [] + for c in _INDEX: + if c["agent"] != agent_id: + continue + scored.append((_cosine(qvec, c["vector"]), c)) + scored.sort(key=lambda x: x[0], reverse=True) + return [(s, c) for s, c in scored[:top_k]] + + +# Global index, built lazily once in a background thread. +_INDEX = [] +_index_lock = threading.Lock() +_index_ready = False + + +def start_background_index(): + def _run(): + global _index_ready + try: + idx = build_index() + with _index_lock: + _INDEX.clear() + _INDEX.extend(idx) + finally: + _index_ready = True + threading.Thread(target=_run, daemon=True).start() + + +def index_status(): + return {"ready": _index_ready, "chunks": len(_INDEX)} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8d24d2d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0 +gunicorn>=21.2 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..427cd82 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,408 @@ + + + + + +Astraea β€” Washington Family Law, Decoded + + + + + +
✦
+ +
+
+
+ +
+

ASTRAEA

+
Washington Family Law, decoded by specialist agents
+
+
+
Engines online
+
+ +
+

Eight specialists.
One constellation of Washington family law.

+

Ask a divorce, custody, child-support, property, or protection-order question. Each specialist answers from the actual Revised Code of Washington β€” with citations β€” not from thin air.

+
+ + +
+ +
+
+
🧭
+
+
Navigator
+
Where to start
+
+
+
+
+
+ + +
+
Answers cite the Revised Code of Washington. This is legal information, not advice.
+
+ +
{{ disclaimer }}
+ +
+ + + + + + +