File size: 11,682 Bytes
da4758c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
"""Orchestrates the agents: Planner -> Researcher -> Retriever -> Writer -> Checker -> Reviser -> Translator."""
import re
import time
import types

from src import agents, config, llm
from src.documents import load_documents
from src.evidence import EvidenceError, EvidenceStore
from src.search import search_topic

DEFAULT_LLM = types.SimpleNamespace(complete=llm.complete, stream=llm.stream_completion)

_ICONS = {"run": "⏳", "ok": "βœ…", "warn": "⚠️", "fail": "❌"}


class Trace:
    """The live 'agent activity' log shown in the UI."""

    def __init__(self):
        self.steps = []

    def start(self, name: str, detail: str = "") -> int:
        self.steps.append({"name": name, "status": "run", "detail": detail, "items": []})
        return len(self.steps) - 1

    def finish(self, idx: int, detail: str = None, status: str = "ok", items: list = None):
        step = self.steps[idx]
        step["status"] = status
        if detail is not None:
            step["detail"] = detail
        if items:
            step["items"] = items

    def render(self) -> str:
        lines = ["### πŸ€– Agent activity", ""]
        for s in self.steps:
            lines.append(f"{_ICONS[s['status']]} **{s['name']}** β€” {s['detail']}".rstrip(" β€”"))
            for item in s["items"]:
                lines.append(f"   - {item}")
        return "\n".join(lines)


def _event(trace, report=None, done=False, error=False, result=None):
    return {"trace": trace.render(), "report": report, "done": done, "error": error, "result": result}


def sources_markdown(sources: list) -> str:
    lines = []
    for s in sources:
        if s.get("kind") == "document":
            lines.append(f"{s['n']}. πŸ“„ **{s['title']}** (uploaded document)")
        else:
            title = re.sub(r"[\[\]]", "", s["title"]) or s["url"]
            lines.append(f"{s['n']}. [{title}]({s['url']})")
    return "\n\n---\n\n## Sources\n\n" + "\n".join(lines)


def verification_markdown(final: dict, first: dict, revised: bool) -> str:
    if not final or not final["checked"]:
        return "\n\n> πŸ”Ž **Verification:** the automatic fact-check was unavailable for this report. Please verify key facts yourself."
    text = f"{final['supported']} of {final['checked']} cited claims are fully supported by their sources"
    extras = []
    if final["partial"]:
        extras.append(f"{final['partial']} partially supported")
    if final["unsupported"]:
        extras.append(f"{final['unsupported']} unsupported")
    if extras:
        text += " (" + ", ".join(extras) + ")"
    if revised and first and first["checked"]:
        text += f". The first draft scored {first['supported']}/{first['checked']}; the Reviser agent corrected the rest"
    return f"\n\n> πŸ”Ž **Verification:** {text}. This is an automated check β€” verify critical facts yourself."


def _check(report, store, complete):
    claims, uncited = agents.extract_claims(report)
    results = agents.check_claims(claims, store, complete) if claims else {}
    return claims, results, agents.summarize_check(claims, results, uncited)


def run_research(
    topic: str,
    mode: str = "Standard",
    language: str = "English",
    depth: str = config.DEFAULT_DEPTH,
    use_web: bool = True,
    document_paths=None,
    *,
    searcher=None,
    embedder=None,
    llm_api=None,
):
    """Generator. Yields {"trace", "report", "done", "error", "result"} events as the agents work.

    The final event has done=True and a `result` dict with the report, sources and fact-check metrics.
    `searcher`, `embedder` and `llm_api` can be replaced for testing.
    """
    started = time.time()
    searcher = searcher or search_topic
    llm_api = llm_api or DEFAULT_LLM
    cfg = config.DEPTH_PRESETS.get(depth, config.DEPTH_PRESETS[config.DEFAULT_DEPTH])
    trace = Trace()

    topic = (topic or "").strip()
    if len(topic) < 2:
        yield _event(trace, report="### ⚠️ Please enter a research topic.", error=True)
        return

    # ---- 0. uploaded documents ---------------------------------------------------------
    documents = []
    if document_paths:
        step = trace.start("Documents", "reading uploaded files...")
        yield _event(trace)
        documents, warnings = load_documents(document_paths)
        trace.finish(
            step,
            f"{len(documents)} file(s) loaded" if documents else "no readable documents",
            "ok" if documents else "warn",
            items=warnings,
        )

    if not use_web and not documents:
        yield _event(trace, report="### ⚠️ Web search is off and no readable documents were uploaded, so there is nothing to research.", error=True)
        return

    # ---- 1. Planner --------------------------------------------------------------------
    step = trace.start("Planner", "splitting the topic into research questions...")
    yield _event(trace)
    try:
        questions = agents.plan(topic, cfg["sub_questions"], llm_api.complete)
        trace.finish(step, f"{len(questions)} research question(s)", items=[f"{i}. {q}" for i, q in enumerate(questions, 1)])
    except llm.LLMError as e:
        questions = [topic]
        trace.finish(step, f"planner unavailable ({e}); using the topic as a single question", "warn")
    yield _event(trace)

    # ---- 2. Researcher -----------------------------------------------------------------
    sources, errors = list(documents), []
    if use_web:
        step = trace.start("Researcher", f"searching the web ({len(questions)} parallel searches)...")
        yield _event(trace)
        web_sources, errors = agents.research_web(questions, cfg, searcher)
        sources = web_sources + sources
        if web_sources:
            trace.finish(step, f"{len(web_sources)} web source(s) found", items=[f"note: {e}" for e in errors])
        else:
            trace.finish(step, "no web results", "warn", items=errors)
        yield _event(trace)

    if not sources:
        message = "### ⚠️ " + (errors[0] if use_web and errors else "No sources were found for this topic.")
        yield _event(trace, report=message, error=True)
        return

    # ---- 3. Index + retrieve -----------------------------------------------------------
    step = trace.start("Retriever", "chunking, embedding and indexing sources...")
    yield _event(trace)
    try:
        store = EvidenceStore(sources, embedder)
        selected, numbered = store.gather(questions, cfg["chunks_per_question"])
    except EvidenceError as e:
        trace.finish(step, str(e), "fail")
        yield _event(trace, report=f"### ⚠️ {e}", error=True)
        return
    except Exception as e:
        print(f"[DEBUG ERROR] Retrieval failed: {e}")
        trace.finish(step, f"retrieval failed: {e}", "fail")
        yield _event(trace, report=f"### ❌ Retrieval failed: `{e}`", error=True)
        return
    trace.finish(step, f"{len(store.records)} passages indexed, {len(selected)} selected from {len(numbered)} source(s)")
    yield _event(trace)

    # ---- 4. Writer ---------------------------------------------------------------------
    step = trace.start("Writer", "drafting the cited report...")
    draft = ""
    try:
        for delta in agents.write_stream(topic, mode, questions, selected, numbered, llm_api.stream):
            draft += delta
            shown = agents.clean_citations(draft, len(numbered))
            yield _event(trace, report="*Draft β€” fact-checking will follow...*\n\n" + shown)
    except llm.LLMError as e:
        trace.finish(step, str(e), "fail")
        yield _event(trace, report=f"### ❌ {e}", error=True)
        return
    report = agents.clean_citations(draft, len(numbered)).strip()
    if not report:
        trace.finish(step, "empty answer", "fail")
        yield _event(trace, report="### ⚠️ The model returned an empty answer. Please try again.", error=True)
        return
    trace.finish(step, f"draft written ({len(report.split())} words)")
    yield _event(trace, report="*Draft β€” fact-checking in progress...*\n\n" + report)

    # ---- 5. Checker (+ Reviser loop) ---------------------------------------------------
    first_metrics = final_metrics = None
    revised_flag = False
    step = trace.start("Checker", "verifying every cited claim against its sources...")
    yield _event(trace, report="*Draft β€” fact-checking in progress...*\n\n" + report)
    try:
        claims, results, first_metrics = _check(report, store, llm_api.complete)
        final_metrics = first_metrics
        if first_metrics["checked"]:
            m = first_metrics
            trace.finish(step, f"{m['supported']}/{m['checked']} claims supported, {m['partial']} partial, {m['unsupported']} unsupported")
        else:
            trace.finish(step, "no claims could be verified", "warn")
        yield _event(trace, report="*Draft β€” fact-checking in progress...*\n\n" + report)

        for _ in range(config.MAX_REVISION_ROUNDS):
            flagged = final_metrics["partial"] + final_metrics["unsupported"]
            if not flagged:
                break
            step = trace.start("Reviser", f"rewriting {flagged} flagged claim(s)...")
            yield _event(trace, report="*Draft β€” revising flagged claims...*\n\n" + report)
            candidate = agents.revise(report, claims, results, store, llm_api.complete)
            if candidate == report:
                trace.finish(step, "no usable revision", "warn")
                break
            claims2, results2, metrics2 = _check(candidate, store, llm_api.complete)
            old_rate = final_metrics["support_rate"] or 0
            new_rate = metrics2["support_rate"] or 0
            if metrics2["checked"] and new_rate >= old_rate:
                report, claims, results, final_metrics, revised_flag = candidate, claims2, results2, metrics2, True
                trace.finish(step, f"revised; now {metrics2['supported']}/{metrics2['checked']} claims supported")
            else:
                trace.finish(step, "revision did not improve the report; kept the original draft", "warn")
            yield _event(trace, report=report)
    except llm.LLMError as e:
        trace.finish(step, f"unavailable ({e}); report delivered unverified", "warn")
        yield _event(trace, report=report)

    # ---- 6. Translator -----------------------------------------------------------------
    english_report = report
    if language != "English":
        step = trace.start("Translator", f"translating the verified report to {language}...")
        yield _event(trace, report=report)
        try:
            report = agents.translate(report, language, llm_api.complete)
            trace.finish(step, f"translated to {language}")
        except llm.LLMError as e:
            trace.finish(step, f"translation failed ({e}); showing English", "warn")

    # ---- 7. Assemble -------------------------------------------------------------------
    final_md = report + sources_markdown(numbered) + verification_markdown(final_metrics, first_metrics, revised_flag)
    elapsed = round(time.time() - started, 1)
    result = {
        "topic": topic,
        "report": final_md,
        "report_english": english_report,
        "language": language,
        "questions": questions,
        "sources": numbered,
        "metrics": {
            "first_pass": first_metrics,
            "final": final_metrics,
            "revised": revised_flag,
            "sources": len(numbered),
            "passages_indexed": len(store.records),
            "seconds": elapsed,
        },
    }
    yield _event(trace, report=final_md, done=True, result=result)