Jun 29 - Improve AI analysis function fix 429 error

This commit is contained in:
2026-06-29 17:47:43 -04:00
parent d4917a0663
commit 57b11b2aa9
+34 -66
View File
@@ -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. # JSON payload well under Groq's request size limit on all plan tiers.
_TEXT_LIMIT = 14_000 _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. # Analyst persona — injected as the system message in every API call.
_SYSTEM_PROMPT = ( _SYSTEM_PROMPT = (
"You are a senior government procurement analyst supporting a small business BD team. " "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:
{documents}""" {documents}"""
# Stage 2 — criteria evaluation (separate API call, only when active criteria exist). # Criteria evaluation — appended to the extraction prompt in the same API call.
# Receives a capped slice of the stage 1 summary — not the raw documents — so the # A single call avoids the rate-limit (429) that two back-to-back calls trigger
# model can focus on evaluation without re-parsing source noise. # on Groq's free tier.
_CRITERIA_PROMPT = """\ _CRITERIA_SUFFIX = """
Below is an extracted summary of a government solicitation. Evaluate whether our
company should pursue it based on the criteria listed.
## OPPORTUNITY SUMMARY
{summary}
--- ---
## ALIGNMENT EVALUATION ## Opportunity Alignment Evaluation
Our criteria: Now evaluate this opportunity against our company's criteria below.
**Our criteria:**
{criteria_list} {criteria_list}
For each criterion, state: For each criterion provide:
- ✅ MEETS / ⚠️ PARTIALLY MEETS / ❌ DOES NOT MEET / ❓ CANNOT DETERMINE - ✅ MEETS / ⚠️ PARTIALLY MEETS / ❌ DOES NOT MEET / ❓ CANNOT DETERMINE
- One or two sentences citing specific details from the summary. - 12 sentences with specific evidence from the documents above.
--- **Overall Recommendation** — write exactly one of these lines (machine-read, do not alter):
## Overall Recommendation
Write your recommendation on its own line in exactly one of these forms
(machine-read — do not alter the label):
RECOMMENDATION: PURSUE RECOMMENDATION: PURSUE
RECOMMENDATION: PASS 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. PASS = fails one or more critical criteria or presents unacceptable risk.
UNCLEAR = insufficient information for a confident decision. UNCLEAR = insufficient information for a confident decision.
## Executive Summary **Executive Summary:** 34 sentences in plain business language explaining the recommendation."""
34 sentences in plain business language: strongest reasons to pursue or pass,
and the single biggest risk or opportunity."""
def _call_groq_api(api_key: str, model: str, user_message: str) -> str: def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
"""Single Groq chat completions call. Raises on HTTP error.""" """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( response = http_requests.post(
"https://api.groq.com/openai/v1/chat/completions", "https://api.groq.com/openai/v1/chat/completions",
headers={ headers={
@@ -374,7 +373,7 @@ def _call_groq_api(api_key: str, model: str, user_message: str) -> str:
"model": model, "model": model,
"messages": [ "messages": [
{"role": "system", "content": _SYSTEM_PROMPT}, {"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_message}, {"role": "user", "content": prompt},
], ],
"max_tokens": 4096, "max_tokens": 4096,
"temperature": 0.1, "temperature": 0.1,
@@ -384,50 +383,19 @@ def _call_groq_api(api_key: str, model: str, user_message: str) -> str:
if not response.ok: if not response.ok:
logger.error(f"Groq API {response.status_code}: {response.text[:300]}") logger.error(f"Groq API {response.status_code}: {response.text[:300]}")
response.raise_for_status() response.raise_for_status()
return response.json()["choices"][0]["message"]["content"] or ""
content = 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)
verdict = None verdict = None
if criteria:
match = re.search( match = re.search(
r"RECOMMENDATION\s*:\s*(PURSUE|PASS|UNCLEAR)", r"RECOMMENDATION\s*:\s*(PURSUE|PASS|UNCLEAR)",
evaluation, re.IGNORECASE, content, re.IGNORECASE,
) )
if match: if match:
verdict = match.group(1).upper() verdict = match.group(1).upper()
combined = summary + "\n\n---\n\n" + evaluation return {"verdict": verdict, "summary": content, "truncated": truncated}
return {"verdict": verdict, "summary": combined, "truncated": truncated}
# ─── Criteria Management (admin only) ───────────────────────────────────────── # ─── Criteria Management (admin only) ─────────────────────────────────────────