diff --git a/routes/ai_summary.py b/routes/ai_summary.py index 60307e1..b6de276 100644 --- a/routes/ai_summary.py +++ b/routes/ai_summary.py @@ -187,10 +187,6 @@ _OFFICE_ADDRESS = "2815 Hartland Road, Falls Church, VA 22043, USA" # JSON payload well under Groq's request size limit on all plan tiers. _TEXT_LIMIT = 14_000 -# Summary passed to stage 2. Stage 1 can produce up to ~16 KB of output; -# capping it here prevents stage 2's payload from growing unbounded. -_SUMMARY_LIMIT = 8_000 - # Analyst persona — injected as the system message in every API call. _SYSTEM_PROMPT = ( "You are a senior government procurement analyst supporting a small business BD team. " @@ -319,34 +315,25 @@ Our office: **{office}** — use as origin for travel estimates. DOCUMENTS: {documents}""" -# Stage 2 — criteria evaluation (separate API call, only when active criteria exist). -# Receives a capped slice of the stage 1 summary — not the raw documents — so the -# model can focus on evaluation without re-parsing source noise. -_CRITERIA_PROMPT = """\ -Below is an extracted summary of a government solicitation. Evaluate whether our -company should pursue it based on the criteria listed. - -## OPPORTUNITY SUMMARY - -{summary} +# Criteria evaluation — appended to the extraction prompt in the same API call. +# A single call avoids the rate-limit (429) that two back-to-back calls trigger +# on Groq's free tier. +_CRITERIA_SUFFIX = """ --- -## ALIGNMENT EVALUATION +## Opportunity Alignment Evaluation -Our criteria: +Now evaluate this opportunity against our company's criteria below. + +**Our criteria:** {criteria_list} -For each criterion, state: +For each criterion provide: - ✅ MEETS / ⚠️ PARTIALLY MEETS / ❌ DOES NOT MEET / ❓ CANNOT DETERMINE -- One or two sentences citing specific details from the summary. +- 1–2 sentences with specific evidence from the documents above. ---- - -## Overall Recommendation - -Write your recommendation on its own line in exactly one of these forms -(machine-read — do not alter the label): +**Overall Recommendation** — write exactly one of these lines (machine-read, do not alter): RECOMMENDATION: PURSUE RECOMMENDATION: PASS @@ -356,14 +343,26 @@ PURSUE = clearly aligns with most criteria and is competitive. PASS = fails one or more critical criteria or presents unacceptable risk. UNCLEAR = insufficient information for a confident decision. -## Executive Summary - -3–4 sentences in plain business language: strongest reasons to pursue or pass, -and the single biggest risk or opportunity.""" +**Executive Summary:** 3–4 sentences in plain business language explaining the recommendation.""" -def _call_groq_api(api_key: str, model: str, user_message: str) -> str: - """Single Groq chat completions call. Raises on HTTP error.""" +def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict: + """Single Groq API call: extraction + optional criteria evaluation in one request.""" + truncated = len(text) > _TEXT_LIMIT + doc_text = text[:_TEXT_LIMIT] + n = doc_text.count("=== ") or 1 + + prompt = _EXTRACTION_PROMPT.format( + n=n, office=_OFFICE_ADDRESS, documents=doc_text + ) + + if criteria: + criteria_list = "\n".join( + f"{i+1}. **{c['title']}**: {c['description']}" + for i, c in enumerate(criteria) + ) + prompt += _CRITERIA_SUFFIX.format(criteria_list=criteria_list) + response = http_requests.post( "https://api.groq.com/openai/v1/chat/completions", headers={ @@ -374,7 +373,7 @@ def _call_groq_api(api_key: str, model: str, user_message: str) -> str: "model": model, "messages": [ {"role": "system", "content": _SYSTEM_PROMPT}, - {"role": "user", "content": user_message}, + {"role": "user", "content": prompt}, ], "max_tokens": 4096, "temperature": 0.1, @@ -384,50 +383,19 @@ def _call_groq_api(api_key: str, model: str, user_message: str) -> str: if not response.ok: logger.error(f"Groq API {response.status_code}: {response.text[:300]}") response.raise_for_status() - return response.json()["choices"][0]["message"]["content"] or "" - -def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict: - """ - Two-stage Groq analysis: - Stage 1 (always): extract structured fields + summary from the documents. - Stage 2 (optional): evaluate criteria against the capped stage-1 summary, - not the raw docs, for a focused and reliable result. - """ - truncated = len(text) > _TEXT_LIMIT - doc_text = text[:_TEXT_LIMIT] - n = doc_text.count("=== ") or 1 - - # ── Stage 1: extraction ──────────────────────────────────────────────────── - stage1_prompt = _EXTRACTION_PROMPT.format( - n=n, office=_OFFICE_ADDRESS, documents=doc_text - ) - summary = _call_groq_api(api_key, model, stage1_prompt) - - if not criteria: - return {"verdict": None, "summary": summary, "truncated": truncated} - - # ── Stage 2: criteria evaluation ─────────────────────────────────────────── - criteria_list = "\n".join( - f"{i+1}. **{c['title']}**: {c['description']}" - for i, c in enumerate(criteria) - ) - stage2_prompt = _CRITERIA_PROMPT.format( - summary=summary[:_SUMMARY_LIMIT], # cap to control payload size - criteria_list=criteria_list, - ) - evaluation = _call_groq_api(api_key, model, stage2_prompt) + content = response.json()["choices"][0]["message"]["content"] or "" verdict = None - match = re.search( - r"RECOMMENDATION\s*:\s*(PURSUE|PASS|UNCLEAR)", - evaluation, re.IGNORECASE, - ) - if match: - verdict = match.group(1).upper() + if criteria: + match = re.search( + r"RECOMMENDATION\s*:\s*(PURSUE|PASS|UNCLEAR)", + content, re.IGNORECASE, + ) + if match: + verdict = match.group(1).upper() - combined = summary + "\n\n---\n\n" + evaluation - return {"verdict": verdict, "summary": combined, "truncated": truncated} + return {"verdict": verdict, "summary": content, "truncated": truncated} # ─── Criteria Management (admin only) ─────────────────────────────────────────