|
|
|
@@ -0,0 +1,339 @@
|
|
|
|
|
"""Boardroom Map reviewer — a sandboxed, ONE-SHOT agent that reads confidential
|
|
|
|
|
documents through a LOCAL model and writes a single report, then exits.
|
|
|
|
|
|
|
|
|
|
Unlike a swarm worker, this never loops forever and never writes to a shared
|
|
|
|
|
workspace. It mounts the documents read-only at /docs, runs one model (through the
|
|
|
|
|
on-Spark proxy) under its PERSONA + the shared RUBRIC, and writes exactly one
|
|
|
|
|
file to /out:
|
|
|
|
|
role=reviewer -> /out/<BM_REVIEWER_ID>.md
|
|
|
|
|
role=synthesizer -> /out/CONSOLIDATED_REPORT.md (also reads /reports)
|
|
|
|
|
|
|
|
|
|
It speaks the OpenAI-compatible /v1/chat/completions API directly (no Claude CLI,
|
|
|
|
|
no Anthropic translation — small local models handle this far better). The whole
|
|
|
|
|
document set is pre-loaded into the prompt up to a budget; for anything larger the
|
|
|
|
|
model can pull more with read_file. Native tool_calls are used when available,
|
|
|
|
|
with a JSON-action text fallback for models without a vLLM tool parser.
|
|
|
|
|
|
|
|
|
|
In air-gapped mode the container is on an --internal Docker network: the only
|
|
|
|
|
thing reachable is the model proxy. web_search is offered ONLY when BM_SEARXNG_URL
|
|
|
|
|
is set (local-services mode).
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import ssl
|
|
|
|
|
import time
|
|
|
|
|
import traceback
|
|
|
|
|
import urllib.parse
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
|
|
|
|
_SSL_CTX = ssl._create_unverified_context() # LAN self-signed (SearXNG)
|
|
|
|
|
|
|
|
|
|
RID = os.environ.get("BM_REVIEWER_ID", "reviewer")
|
|
|
|
|
NAME = os.environ.get("BM_REVIEWER_NAME", RID)
|
|
|
|
|
ROLE = os.environ.get("BM_ROLE", "reviewer") # reviewer | synthesizer
|
|
|
|
|
MODEL = os.environ.get("BM_MODEL", "reviewer-a")
|
|
|
|
|
LLM_BASE = os.environ.get("BM_LLM_BASE", "http://boardroom-proxy:4000/v1").rstrip("/")
|
|
|
|
|
LLM_KEY = os.environ.get("BM_LLM_KEY", "sk-local")
|
|
|
|
|
SEARXNG_URL = os.environ.get("BM_SEARXNG_URL", "").rstrip("/")
|
|
|
|
|
try:
|
|
|
|
|
TEMPERATURE = float(os.environ.get("BM_TEMPERATURE", "") or "0.3")
|
|
|
|
|
except ValueError:
|
|
|
|
|
TEMPERATURE = 0.3
|
|
|
|
|
try:
|
|
|
|
|
MAX_MODEL_LEN = int(os.environ.get("BM_MAX_MODEL_LEN", "32768"))
|
|
|
|
|
except ValueError:
|
|
|
|
|
MAX_MODEL_LEN = 32768
|
|
|
|
|
|
|
|
|
|
DOCS = "/docs"
|
|
|
|
|
REPORTS = "/reports"
|
|
|
|
|
OUT_DIR = "/out"
|
|
|
|
|
PERSONA_PATH = "/persona/PERSONA.md"
|
|
|
|
|
RUBRIC_PATH = "/RUBRIC.md"
|
|
|
|
|
|
|
|
|
|
# Leave headroom for the system/rubric/persona + the model's output; spend the
|
|
|
|
|
# rest on document text (~3 chars/token is a safe rough estimate).
|
|
|
|
|
DOC_BUDGET = max(8000, (MAX_MODEL_LEN - 3500) * 3)
|
|
|
|
|
MAX_STEPS = 8
|
|
|
|
|
MAX_OUTPUT_TOKENS = 2048
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- io helpers
|
|
|
|
|
def read_text(path: str, limit: int = 1_000_000) -> str:
|
|
|
|
|
try:
|
|
|
|
|
with open(path, errors="replace") as f:
|
|
|
|
|
return f.read()[:limit]
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _roots() -> list[str]:
|
|
|
|
|
return [DOCS, REPORTS] if ROLE == "synthesizer" else [DOCS]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe(path: str) -> str:
|
|
|
|
|
"""Resolve a path inside an allowed read root; refuse escapes."""
|
|
|
|
|
cand = path or "."
|
|
|
|
|
for root in _roots():
|
|
|
|
|
p = os.path.realpath(os.path.join(root, cand) if not os.path.isabs(cand) else cand)
|
|
|
|
|
if p == root or p.startswith(root + os.sep):
|
|
|
|
|
return p
|
|
|
|
|
raise ValueError(f"path outside allowed roots: {path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_dir(root: str) -> list[str]:
|
|
|
|
|
out = []
|
|
|
|
|
if not os.path.isdir(root):
|
|
|
|
|
return out
|
|
|
|
|
for r, _dirs, files in os.walk(root):
|
|
|
|
|
for fn in files:
|
|
|
|
|
out.append(os.path.relpath(os.path.join(r, fn), root))
|
|
|
|
|
return sorted(out)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- tools
|
|
|
|
|
def tool_list_files(args: dict) -> str:
|
|
|
|
|
lines = []
|
|
|
|
|
for root in _roots():
|
|
|
|
|
names = list_dir(root)
|
|
|
|
|
if names:
|
|
|
|
|
lines.append(f"{root}:")
|
|
|
|
|
lines += [f" {n}" for n in names]
|
|
|
|
|
return "\n".join(lines) or "(no files)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tool_read_file(args: dict) -> str:
|
|
|
|
|
p = _safe(args["path"])
|
|
|
|
|
try:
|
|
|
|
|
with open(p, errors="replace") as f:
|
|
|
|
|
return f.read()[:20000]
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return f"(no such file: {args['path']})"
|
|
|
|
|
except IsADirectoryError:
|
|
|
|
|
return f"(is a directory: {args['path']})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tool_web_search(args: dict) -> str:
|
|
|
|
|
if not SEARXNG_URL:
|
|
|
|
|
return "web search unavailable"
|
|
|
|
|
q = urllib.parse.urlencode({"q": args.get("query", ""), "format": "json"})
|
|
|
|
|
try:
|
|
|
|
|
req = urllib.request.Request(f"{SEARXNG_URL}/search?{q}", headers={"User-Agent": "boardroom-grader"})
|
|
|
|
|
with urllib.request.urlopen(req, timeout=20, context=_SSL_CTX) as resp:
|
|
|
|
|
data = json.loads(resp.read().decode())
|
|
|
|
|
lines = [f"- {r.get('title','')}\n {r.get('url','')}\n {r.get('content','')[:300]}"
|
|
|
|
|
for r in (data.get("results") or [])[:8]]
|
|
|
|
|
return "\n".join(lines) or "(no results)"
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return f"search error: {e}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DISPATCH = {"list_files": tool_list_files, "read_file": tool_read_file, "web_search": tool_web_search}
|
|
|
|
|
|
|
|
|
|
TOOLS = [
|
|
|
|
|
{"type": "function", "function": {
|
|
|
|
|
"name": "list_files", "description": "List the available document (and report) files.",
|
|
|
|
|
"parameters": {"type": "object", "properties": {}}}},
|
|
|
|
|
{"type": "function", "function": {
|
|
|
|
|
"name": "read_file", "description": "Read a document or report file by its path (from list_files).",
|
|
|
|
|
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}},
|
|
|
|
|
]
|
|
|
|
|
if SEARXNG_URL:
|
|
|
|
|
TOOLS.append({"type": "function", "function": {
|
|
|
|
|
"name": "web_search", "description": "Search the web via SearXNG; returns top results.",
|
|
|
|
|
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_tool(name: str, args: dict) -> str:
|
|
|
|
|
fn = DISPATCH.get(name)
|
|
|
|
|
if not fn:
|
|
|
|
|
return f"(unknown tool: {name})"
|
|
|
|
|
try:
|
|
|
|
|
return fn(args)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return f"(tool error: {e})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- LLM
|
|
|
|
|
def chat(messages: list) -> dict:
|
|
|
|
|
body = {"model": MODEL, "messages": messages, "tools": TOOLS,
|
|
|
|
|
"tool_choice": "auto", "temperature": TEMPERATURE, "max_tokens": MAX_OUTPUT_TOKENS}
|
|
|
|
|
req = urllib.request.Request(
|
|
|
|
|
f"{LLM_BASE}/chat/completions",
|
|
|
|
|
data=json.dumps(body).encode(),
|
|
|
|
|
headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM_KEY}"},
|
|
|
|
|
method="POST")
|
|
|
|
|
with urllib.request.urlopen(req, timeout=300, context=_SSL_CTX) as resp:
|
|
|
|
|
data = json.loads(resp.read().decode())
|
|
|
|
|
return data["choices"][0]["message"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_JSON_ACTION = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _text_fallback_calls(content: str) -> list:
|
|
|
|
|
if not content:
|
|
|
|
|
return []
|
|
|
|
|
m = _JSON_ACTION.search(content)
|
|
|
|
|
if not m:
|
|
|
|
|
return []
|
|
|
|
|
try:
|
|
|
|
|
obj = json.loads(m.group(1))
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
return []
|
|
|
|
|
name = obj.get("tool") or obj.get("name")
|
|
|
|
|
if name in DISPATCH:
|
|
|
|
|
return [{"id": "fallback", "function": {"name": name, "arguments": json.dumps(obj.get("args", {}))}}]
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- prompts
|
|
|
|
|
def _preload_docs() -> tuple[str, bool]:
|
|
|
|
|
"""Concatenate the document text up to DOC_BUDGET. Returns (text, truncated)."""
|
|
|
|
|
names = list_dir(DOCS)
|
|
|
|
|
chunks, used, truncated = [], 0, False
|
|
|
|
|
for n in names:
|
|
|
|
|
body = read_text(os.path.join(DOCS, n))
|
|
|
|
|
header = f"\n\n========== DOCUMENT: {n} ==========\n"
|
|
|
|
|
room = DOC_BUDGET - used
|
|
|
|
|
if room <= 0:
|
|
|
|
|
truncated = True
|
|
|
|
|
break
|
|
|
|
|
seg = (header + body)[:room]
|
|
|
|
|
if len(header + body) > room:
|
|
|
|
|
truncated = True
|
|
|
|
|
chunks.append(seg)
|
|
|
|
|
used += len(seg)
|
|
|
|
|
return "".join(chunks), truncated
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _preload_reports() -> str:
|
|
|
|
|
names = list_dir(REPORTS)
|
|
|
|
|
parts = []
|
|
|
|
|
budget = DOC_BUDGET
|
|
|
|
|
used = 0
|
|
|
|
|
for n in names:
|
|
|
|
|
body = read_text(os.path.join(REPORTS, n))
|
|
|
|
|
header = f"\n\n========== REVIEWER REPORT: {n} ==========\n"
|
|
|
|
|
seg = (header + body)[: max(0, budget - used)]
|
|
|
|
|
parts.append(seg)
|
|
|
|
|
used += len(seg)
|
|
|
|
|
return "".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def system_prompt() -> str:
|
|
|
|
|
persona = read_text(PERSONA_PATH).strip()
|
|
|
|
|
if ROLE == "synthesizer":
|
|
|
|
|
base = (f"You are '{NAME}', the lead reviewer chairing a document-review panel. "
|
|
|
|
|
"You are given the panel members' individual reports (and the source "
|
|
|
|
|
"documents for reference). Produce ONE consolidated report in Markdown.")
|
|
|
|
|
else:
|
|
|
|
|
base = (f"You are '{NAME}', an expert confidential-document reviewer. Read the "
|
|
|
|
|
"document(s) provided and produce ONE written report in Markdown. Base every "
|
|
|
|
|
"statement on the documents; never invent facts. Be specific and cite the "
|
|
|
|
|
"document/section for each point.")
|
|
|
|
|
if persona:
|
|
|
|
|
base += "\n\n# YOUR LENS — how YOU specifically read this\n" + persona
|
|
|
|
|
tools_note = (
|
|
|
|
|
"\n\nYou can call list_files and read_file to pull more content if what was "
|
|
|
|
|
"pre-loaded is truncated"
|
|
|
|
|
+ (", and web_search for external context" if SEARXNG_URL else "")
|
|
|
|
|
+ ". When done, reply with the FINAL report only — no tool call. If your client "
|
|
|
|
|
'cannot emit tool calls, reply with a single fenced block: '
|
|
|
|
|
'```json\\n{"tool":"read_file","args":{"path":"..."}}\\n``` and nothing else.'
|
|
|
|
|
)
|
|
|
|
|
return base + tools_note
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def first_user_message() -> str:
|
|
|
|
|
rubric = read_text(RUBRIC_PATH).strip() or "Produce a thorough review report."
|
|
|
|
|
if ROLE == "synthesizer":
|
|
|
|
|
reports = _preload_reports()
|
|
|
|
|
docs, truncated = _preload_docs()
|
|
|
|
|
return (f"# REVIEW RUBRIC\n{rubric}\n\n# PANEL REPORTS\n{reports}\n\n"
|
|
|
|
|
f"# SOURCE DOCUMENTS (for reference){' (truncated)' if truncated else ''}\n{docs}\n\n"
|
|
|
|
|
"# YOUR TASK\nConsolidate the panel's reports into one authoritative report per the "
|
|
|
|
|
"rubric: shared findings, conflicts (and your adjudication), anything only one "
|
|
|
|
|
"reviewer caught, and a prioritized overall recommendation. Attribute points to "
|
|
|
|
|
"reviewers. Output the final consolidated report now.")
|
|
|
|
|
docs, truncated = _preload_docs()
|
|
|
|
|
note = ("\n\n(Note: the documents were truncated to fit context — use read_file to pull any "
|
|
|
|
|
"section you need in full.)" if truncated else "")
|
|
|
|
|
return (f"# REVIEW RUBRIC\n{rubric}\n\n# DOCUMENT(S)\n{docs}{note}\n\n"
|
|
|
|
|
"# YOUR TASK\nReview the document(s) above per the rubric and your lens. Output your "
|
|
|
|
|
"final report now.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- run
|
|
|
|
|
def out_path() -> str:
|
|
|
|
|
name = "CONSOLIDATED_REPORT.md" if ROLE == "synthesizer" else f"{RID}.md"
|
|
|
|
|
return os.path.join(OUT_DIR, name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_report(text: str) -> None:
|
|
|
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
|
|
|
with open(out_path(), "w") as f:
|
|
|
|
|
f.write(text.strip() + "\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run() -> str:
|
|
|
|
|
messages = [{"role": "system", "content": system_prompt()},
|
|
|
|
|
{"role": "user", "content": first_user_message()}]
|
|
|
|
|
last_text = ""
|
|
|
|
|
for _ in range(MAX_STEPS):
|
|
|
|
|
msg = chat(messages)
|
|
|
|
|
content = msg.get("content") or ""
|
|
|
|
|
calls = msg.get("tool_calls") or []
|
|
|
|
|
if content.strip():
|
|
|
|
|
last_text = content.strip()
|
|
|
|
|
if not calls:
|
|
|
|
|
calls = _text_fallback_calls(content)
|
|
|
|
|
if not calls:
|
|
|
|
|
break # final report
|
|
|
|
|
messages.append({"role": "assistant", "content": content})
|
|
|
|
|
for c in calls:
|
|
|
|
|
args = json.loads(c["function"]["arguments"] or "{}")
|
|
|
|
|
res = run_tool(c["function"]["name"], args)
|
|
|
|
|
messages.append({"role": "user", "content": f"[tool {c['function']['name']} result]\n{res[:20000]}"})
|
|
|
|
|
continue
|
|
|
|
|
messages.append({"role": "assistant", "content": content or None, "tool_calls": calls})
|
|
|
|
|
for c in calls:
|
|
|
|
|
try:
|
|
|
|
|
args = json.loads(c["function"]["arguments"] or "{}")
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
args = {}
|
|
|
|
|
res = run_tool(c["function"]["name"], args)
|
|
|
|
|
messages.append({"role": "tool", "tool_call_id": c.get("id", ""), "content": res[:20000]})
|
|
|
|
|
|
|
|
|
|
# If the model ended on a tool turn with no report text, ask once more plainly.
|
|
|
|
|
if not last_text.strip():
|
|
|
|
|
messages.append({"role": "user", "content": "Now output your final report in Markdown."})
|
|
|
|
|
try:
|
|
|
|
|
last_text = (chat(messages).get("content") or "").strip()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return last_text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
print(f"[{RID}] reviewer starting (role={ROLE} model={MODEL} base={LLM_BASE})", flush=True)
|
|
|
|
|
try:
|
|
|
|
|
report = run()
|
|
|
|
|
if not report.strip():
|
|
|
|
|
report = f"# {NAME}\n\n(The model returned no report text.)"
|
|
|
|
|
write_report(report)
|
|
|
|
|
print(f"[{RID}] report written to {out_path()} ({len(report)} chars)", flush=True)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(traceback.format_exc(), flush=True)
|
|
|
|
|
# Always leave a file so the orchestrator can see this reviewer ran.
|
|
|
|
|
try:
|
|
|
|
|
write_report(f"# {NAME} — ERROR\n\nThis reviewer failed: {e}\n")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|