You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
205 lines
8.7 KiB
205 lines
8.7 KiB
#!/usr/bin/env python3 |
|
"""Run deep research synchronously (no job runner) and pretty-print the board. |
|
|
|
The Phase-3 test harness and a permanent debugging tool: |
|
|
|
python scripts/research_cli.py --topic "kärnkraftsdebattens utveckling 2010-2024" --trips 3 |
|
python scripts/research_cli.py --board <board_id> --trips 2 # deepen existing |
|
python scripts/research_cli.py --board <board_id> --show # just print it |
|
|
|
Interactive flow (scout → approve → dig → answer → report): |
|
|
|
python scripts/research_cli.py --topic "..." --scout # propose threads, wait |
|
python scripts/research_cli.py --board <id> --activate <thread_id> --guidance "..." |
|
python scripts/research_cli.py --board <id> --trips 2 # dig activated threads |
|
python scripts/research_cli.py --board <id> --answer <thread_id> # synthesize + check cites |
|
python scripts/research_cli.py --board <id> --report # write the board report |
|
|
|
Uses the same engine code as the background job (discover_threads, deepen_step, |
|
synthesize_*) but inline in this process, so stack traces land in your terminal. |
|
""" |
|
from __future__ import annotations |
|
|
|
import argparse |
|
import sys |
|
from pathlib import Path |
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
|
|
|
from backend.services.research import board as board_mod # noqa: E402 |
|
from backend.services.research import synthesis # noqa: E402 |
|
from backend.services.research.handlers import _build_llms_from_env # noqa: E402 |
|
|
|
|
|
def _print_board(board: dict, threads: list[dict]) -> None: |
|
print("=" * 78) |
|
print(f"BOARD {board['id']} [{board['status']}] rev={board['revision']}") |
|
print(f" Ämne: {board['topic']}") |
|
if board.get("intro"): |
|
print(f" Intro: {board['intro']}") |
|
for t in threads: |
|
print("-" * 78) |
|
print(f" [{t['origin']}/{t.get('status')}] {t['title']} (djup {t['depth']}) id={t['id']}") |
|
print(f" Fråga: {t['question']}") |
|
if t.get("why"): |
|
print(f" Varför: {t['why']}") |
|
if t.get("guidance"): |
|
print(f" Medskick: {t['guidance']}") |
|
if t.get("answer"): |
|
print(" SVAR:") |
|
for line in t["answer"].splitlines(): |
|
print(f" {line}") |
|
for f in t.get("findings") or []: |
|
src = f.get("source_id") or "?" |
|
who = f.get("speaker") or "" |
|
party = f" ({f['party']})" if f.get("party") else "" |
|
date = f.get("date") or "" |
|
print(f" • {f.get('label')}") |
|
if f.get("detail"): |
|
print(f" {f['detail']}") |
|
if f.get("quote"): |
|
print(f" ”{f['quote']}”") |
|
print(f" — [src:{src}] {who}{party} {date}") |
|
if t.get("open_questions"): |
|
print(" Öppna frågor:") |
|
for q in t["open_questions"]: |
|
print(f" ? {q}") |
|
if t.get("leads"): |
|
print(" Spår:") |
|
for l in t["leads"]: |
|
label = f" ({l['label']})" if l.get("label") else "" |
|
print(f" → [{l.get('kind')}] {l.get('target')}{label}: {l.get('lead')}") |
|
if board.get("report"): |
|
print("-" * 78) |
|
print(" RAPPORT:") |
|
for line in board["report"].splitlines(): |
|
print(f" {line}") |
|
print("=" * 78) |
|
|
|
|
|
def _on_event(ev: dict) -> None: |
|
if ev.get("phase") == "tool": |
|
print(f" [tool] {ev.get('name')} {ev.get('args')}") |
|
elif ev.get("phase") == "finding": |
|
print(f" [fynd] {ev.get('label')}") |
|
|
|
|
|
def main() -> int: |
|
ap = argparse.ArgumentParser(description=__doc__) |
|
ap.add_argument("--topic", help="Create a new board for this topic") |
|
ap.add_argument("--board", help="Existing board id to deepen/show") |
|
ap.add_argument("--trips", type=int, default=0, help="Number of deepen trips to run") |
|
ap.add_argument("--show", action="store_true", help="Only print the board") |
|
ap.add_argument("--scout", action="store_true", |
|
help="With --topic: scout + propose threads (status 'proposed'), then wait") |
|
ap.add_argument("--activate", help="Activate a proposed thread by id") |
|
ap.add_argument("--guidance", help="Guidance text for --activate") |
|
ap.add_argument("--answer", help="Synthesize + print the answer for a thread id") |
|
ap.add_argument("--report", action="store_true", help="Write and print the board report") |
|
args = ap.parse_args() |
|
|
|
if not args.topic and not args.board: |
|
ap.error("--topic or --board required") |
|
|
|
smart, fast = _build_llms_from_env() |
|
|
|
if args.topic: |
|
board = board_mod.create_board(args.topic) |
|
board_id = board["id"] |
|
print(f"Created board {board_id}") |
|
if args.scout: |
|
print("Scouting the corpus…") |
|
material = board_mod.scout_material( |
|
fast, board["topic"], on_event=lambda q: print(f" [sök] {q}") |
|
) |
|
seeds = board_mod.discover_threads( |
|
fast, board, max_threads=board_mod.RESEARCH_PROPOSAL_COUNT, material=material, |
|
) |
|
print(f" intro: {seeds.intro}") |
|
for seed in seeds.threads: |
|
t = board_mod.insert_thread( |
|
board_id, title=seed.title, question=seed.question, why=seed.why, |
|
origin="auto", status="proposed", hints=seed.hints, |
|
) |
|
print(f" ? [{t['id']}] {seed.title} (hints: {', '.join(seed.hints)})") |
|
board_mod.set_board_status(board_id, "awaiting", intro=seeds.intro or None) |
|
print("\nProposals inserted. Activate some with --board <id> --activate <thread_id>.") |
|
return 0 |
|
print("Discovering threads…") |
|
seeds = board_mod.discover_threads(fast, board) |
|
print(f" intro: {seeds.intro}") |
|
for seed in seeds.threads: |
|
t = board_mod.insert_thread( |
|
board_id, title=seed.title, question=seed.question, |
|
why=seed.why, origin="auto", |
|
) |
|
print(f" + [{t['id'][:8]}] {seed.title} (hints: {', '.join(seed.hints)})") |
|
board_mod.set_board_status(board_id, "digging", intro=seeds.intro or None) |
|
else: |
|
board_id = args.board |
|
board = board_mod.get_board(board_id) |
|
if board is None: |
|
print(f"No board {board_id}", file=sys.stderr) |
|
return 1 |
|
|
|
if args.activate: |
|
ok = board_mod.activate_thread(args.activate, board_id, guidance=args.guidance) |
|
print(f"Activated {args.activate}: {ok}" + (f" (medskick: {args.guidance})" if args.guidance else "")) |
|
if ok: |
|
board_mod.bump_revision(board_id) |
|
if not args.trips: |
|
return 0 |
|
|
|
if args.answer: |
|
thread = board_mod.get_thread(args.answer) |
|
if thread is None: |
|
print(f"No thread {args.answer}", file=sys.stderr) |
|
return 1 |
|
answer = synthesis.synthesize_thread_answer(smart, thread) |
|
if not answer: |
|
print("No answer produced (no findings or LLM error).") |
|
return 1 |
|
print(answer) |
|
# Verify every citation id exists in the thread's findings. |
|
cited = set(synthesis.CITE_RE.findall(answer)) |
|
known = {f.get("source_id") for f in (thread.get("findings") or [])} |
|
bad = cited - known |
|
print(f"\n[cites: {len(cited)} used, {len(bad)} ungrounded]") |
|
assert not bad, f"UNGROUNDED CITATIONS: {bad}" |
|
board_mod.set_thread_answer(args.answer, board_id, answer, depth=int(thread["depth"])) |
|
return 0 |
|
|
|
if args.report: |
|
board = board_mod.get_board(board_id) |
|
threads = [t for t in board_mod.get_threads(board_id) if t["status"] == "active"] |
|
report = synthesis.synthesize_report(smart, board, threads) |
|
if not report: |
|
print("No report produced.") |
|
return 1 |
|
board_mod.set_board_report(board_id, report) |
|
_print_board(board_mod.get_board(board_id), board_mod.get_threads(board_id)) |
|
return 0 |
|
|
|
if args.show: |
|
_print_board(board_mod.get_board(board_id), board_mod.get_threads(board_id)) |
|
return 0 |
|
|
|
for i in range(args.trips): |
|
board = board_mod.get_board(board_id) |
|
nxt = board_mod.pick_next_thread(board_id, int(board["target_depth"])) |
|
if nxt is None: |
|
print("All threads at target depth — nothing to do.") |
|
break |
|
print(f"\nTrip {i + 1}/{args.trips}: {nxt['title']} (djup {nxt['depth']} → {nxt['depth'] + 1})") |
|
board_mod.deepen_step( |
|
smart, fast, board_id, thread_id=nxt["id"], on_event=_on_event |
|
) |
|
|
|
if args.trips or args.topic: |
|
board_mod.set_board_status(board_id, "ready") |
|
_print_board(board_mod.get_board(board_id), board_mod.get_threads(board_id)) |
|
return 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
sys.exit(main())
|
|
|