diff --git a/src/ietf_analyzer/search.py b/src/ietf_analyzer/search.py index cbff081..bb1f84a 100644 --- a/src/ietf_analyzer/search.py +++ b/src/ietf_analyzer/search.py @@ -200,11 +200,55 @@ class HybridSearch: return results + def _build_sources(self, search_results: list[dict]) -> tuple[list[dict], str]: + """Build source list and context block from search results.""" + sources_block = "" + sources = [] + for r in search_results: + draft = self.db.get_draft(r["name"]) + if draft is None: + continue + text_preview = draft.full_text[:500] if draft.full_text else "" + sources_block += f"\n---\n**{draft.name}** — {draft.title}\n" + sources_block += f"Abstract: {draft.abstract[:500]}\n" + if text_preview: + sources_block += f"Content excerpt: {text_preview}\n" + sources.append({ + "name": draft.name, + "title": draft.title, + "similarity": r.get("similarity", r.get("score", 0)), + "excerpt": r.get("excerpt", ""), + "match_type": r.get("match_type", ""), + }) + return sources, sources_block + + def search_only(self, question: str, top_k: int = 5) -> dict: + """Search without Claude synthesis (free — only FTS5 + Ollama). + + Returns {sources: [...], has_cached_answer: bool, answer: str|None}. + """ + search_results = self.search(question, top_k=top_k) + if not search_results: + return {"sources": [], "has_cached_answer": False, "answer": None} + + sources, sources_block = self._build_sources(search_results) + + # Check if we already have a cached answer for this question + prompt = ASK_PROMPT.format(question=question, sources_block=sources_block) + phash = _prompt_hash(prompt) + cached = self.db.get_cached_response("_ask_", phash) + + return { + "sources": sources, + "has_cached_answer": cached is not None, + "answer": cached, + } + def ask(self, question: str, top_k: int = 5, cheap: bool = True) -> dict: """Answer a natural language question using search + Claude synthesis. Returns {answer: str, sources: [{name, title, similarity, excerpt}]}. - Caches Claude responses via llm_cache. + Caches Claude responses via llm_cache permanently. """ search_results = self.search(question, top_k=top_k) @@ -214,31 +258,7 @@ class HybridSearch: "sources": [], } - # Build context from top results - sources_block = "" - sources = [] - for r in search_results: - draft = self.db.get_draft(r["name"]) - if draft is None: - continue - - # Title + abstract + first 500 chars of full text - text_preview = "" - if draft.full_text: - text_preview = draft.full_text[:500] - - sources_block += f"\n---\n**{draft.name}** — {draft.title}\n" - sources_block += f"Abstract: {draft.abstract[:500]}\n" - if text_preview: - sources_block += f"Content excerpt: {text_preview}\n" - - sources.append({ - "name": draft.name, - "title": draft.title, - "similarity": r.get("similarity", r.get("score", 0)), - "excerpt": r.get("excerpt", ""), - "match_type": r.get("match_type", ""), - }) + sources, sources_block = self._build_sources(search_results) prompt = ASK_PROMPT.format( question=question, @@ -246,7 +266,7 @@ class HybridSearch: ) phash = _prompt_hash(prompt) - # Check cache + # Check cache — cached answers are served forever (no TTL) cached = self.db.get_cached_response("_ask_", phash) if cached: return { @@ -262,7 +282,7 @@ class HybridSearch: prompt, max_tokens=1024, cheap=cheap ) - # Cache the response + # Cache permanently self.db.cache_response( "_ask_", phash, self.config.claude_model_cheap if cheap else self.config.claude_model, diff --git a/src/webui/app.py b/src/webui/app.py index 25d77a7..9b7e21a 100644 --- a/src/webui/app.py +++ b/src/webui/app.py @@ -45,7 +45,8 @@ from webui.data import ( get_author_network_full, get_citation_graph, get_comparison_data, - get_ask_data, + get_ask_search, + get_ask_synthesize, global_search, ) @@ -325,20 +326,32 @@ def ask_page(): result = None if question: top_k = request.args.get("top", 5, type=int) - result = get_ask_data(db(), question, top_k=top_k) + # Search only (free) — returns sources + cached answer if available + result = get_ask_search(db(), question, top_k=top_k) return render_template("ask.html", question=question, result=result) -@app.route("/api/ask", methods=["POST"]) -def api_ask(): - """Answer a question via hybrid search + Claude. Returns JSON.""" +@app.route("/api/ask/synthesize", methods=["POST"]) +def api_ask_synthesize(): + """Synthesize an answer via Claude (costs tokens, cached permanently). Returns JSON.""" data = request.get_json(force=True, silent=True) if not data or "question" not in data: return jsonify({"error": "Missing 'question' in request body"}), 400 question = data["question"] top_k = data.get("top_k", 5) - cheap = data.get("cheap", True) - result = get_ask_data(db(), question, top_k=top_k, cheap=cheap) + result = get_ask_synthesize(db(), question, top_k=top_k, cheap=True) + return jsonify(result) + + +@app.route("/api/ask", methods=["POST"]) +def api_ask(): + """Search only (free). Returns JSON with sources + cached answer if available.""" + data = request.get_json(force=True, silent=True) + if not data or "question" not in data: + return jsonify({"error": "Missing 'question' in request body"}), 400 + question = data["question"] + top_k = data.get("top_k", 5) + result = get_ask_search(db(), question, top_k=top_k) return jsonify(result) diff --git a/src/webui/data.py b/src/webui/data.py index d4dbd1c..915bd9a 100644 --- a/src/webui/data.py +++ b/src/webui/data.py @@ -1204,11 +1204,18 @@ def get_comparison_data(db: Database, names: list[str]) -> dict | None: } -def get_ask_data(db: Database, question: str, top_k: int = 5, cheap: bool = True) -> dict: - """Run hybrid search + Claude synthesis for a question. +def get_ask_search(db: Database, question: str, top_k: int = 5) -> dict: + """Search-only (free) — returns sources + cached answer if available.""" + from ietf_analyzer.config import Config + from ietf_analyzer.search import HybridSearch - Returns {answer: str, sources: [{name, title, similarity, excerpt}]}. - """ + config = Config.load() + searcher = HybridSearch(config, db) + return searcher.search_only(question, top_k=top_k) + + +def get_ask_synthesize(db: Database, question: str, top_k: int = 5, cheap: bool = True) -> dict: + """Run Claude synthesis (costs tokens, result is cached permanently).""" from ietf_analyzer.config import Config from ietf_analyzer.search import HybridSearch diff --git a/src/webui/templates/ask.html b/src/webui/templates/ask.html index 9e3f571..a44e6c2 100644 --- a/src/webui/templates/ask.html +++ b/src/webui/templates/ask.html @@ -13,24 +13,17 @@ background: linear-gradient(135deg, rgba(30, 41, 59, 0.8), rgba(30, 41, 59, 0.4)); backdrop-filter: blur(10px); } - .source-row { - transition: all 0.15s ease; - } - .source-row:hover { - background: rgba(59, 130, 246, 0.05); - } + .source-row { transition: all 0.15s ease; } + .source-row:hover { background: rgba(59, 130, 246, 0.05); } .loading-spinner { border: 3px solid rgba(59, 130, 246, 0.2); border-top-color: #3b82f6; border-radius: 50%; - width: 24px; - height: 24px; + width: 20px; height: 20px; animation: spin 0.8s linear infinite; display: inline-block; } - @keyframes spin { - to { transform: rotate(360deg); } - } + @keyframes spin { to { transform: rotate(360deg); } } {% endblock %} @@ -38,7 +31,7 @@
Ask natural language questions about IETF AI/agent drafts. Answers are synthesized from the most relevant documents.
+Search across {{ "{:,}".format(stats.total if stats is defined and stats else 434) }} drafts using keyword + semantic similarity. AI synthesis is optional.