feat: implement Groq fallback for Gemini failures and update ops docs

add Groq dependency and NAS config entries for fallback provider
implement Gemini -> Groq fallback flow on quota, timeout, and API errors
validate Groq API key with models listing and runtime invocation path
update AI orchestration logic in runner and ai enrichment pipeline
update TODO document to mark Groq auth, integration, and verification as completed
This commit is contained in:
2026-08-22 15:19:59 +09:00
parent a8202161e2
commit f7e31aef1c
5 changed files with 477 additions and 94 deletions
+263 -26
View File
@@ -1,6 +1,11 @@
import json
import time
# ============================================================
# Gemini rate limiter
# ============================================================
_GEMINI_MIN_INTERVAL = 13.0
_GEMINI_LAST_CALL = 0.0
@@ -17,10 +22,31 @@ def _wait_for_gemini_rate_limit():
_GEMINI_LAST_CALL = time.monotonic()
# ============================================================
# Common AI prompt
# ============================================================
SYSTEM = '''You are a technical literature triage assistant for an automotive radar engineer.
Analyze ONLY the supplied metadata.
Do not invent experimental results, methods, conclusions, or claims that are not supported by the supplied metadata.
Analyze ONLY the supplied metadata or full text.
Do not invent experimental results, methods, conclusions, or claims that are not supported by the supplied information.
The primary technical scope is radar engineering, especially:
- automotive radar
- FMCW radar
- radar waveform design
- radar MIMO
- radar DOA / angle estimation
- radar super-resolution
- radar interference
- automotive radar antenna
- imaging radar / 4D radar
- radar target modeling
- radar signal processing
- radar hardware and RF impairments
Generic communications, acoustic DOA, sonar, medical sensing, or non-radar MIMO/antenna research should receive low relevance unless there is a clear technical connection to radar.
Return:
- relevance: integer from 1 to 5
@@ -69,10 +95,14 @@ AI_RESPONSE_SCHEMA = {
"categories",
"summary",
"reason",
],
],
}
# ============================================================
# Common helpers
# ============================================================
def _make_payload(paper):
return {
"title": paper.title,
@@ -84,6 +114,18 @@ def _make_payload(paper):
}
def _make_full_text_payload(paper, full_text):
return {
"title": paper.title,
"abstract": paper.abstract,
"venue": paper.venue,
"year": paper.year,
"full_text": full_text,
"current_categories": paper.categories,
"current_relevance": paper.relevance,
}
def _apply_result(paper, data):
paper.relevance = max(
1,
@@ -110,6 +152,34 @@ def _apply_result(paper, data):
return paper
def _parse_json_text(text):
if not text:
raise RuntimeError("AI returned empty response")
text = text.strip()
# Defensive handling for models that wrap JSON in Markdown fences.
if text.startswith("```"):
lines = text.splitlines()
if lines:
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
text = "\n".join(lines).strip()
if text.lower().startswith("json"):
text = text[4:].strip()
return json.loads(text)
# ============================================================
# Gemini - metadata / abstract
# ============================================================
def enrich_with_gemini(paper, api_key, model):
if not api_key:
return paper
@@ -139,13 +209,18 @@ def enrich_with_gemini(paper, api_key, model):
),
)
data = json.loads(response.text)
data = _parse_json_text(response.text)
paper = _apply_result(paper, data)
paper.ai_analysis_level = "abstract"
return paper
# ============================================================
# Gemini - full text
# ============================================================
def enrich_with_gemini_full_text(
paper,
full_text,
@@ -163,15 +238,10 @@ def enrich_with_gemini_full_text(
client = genai.Client(api_key=api_key)
payload = {
"title": paper.title,
"abstract": paper.abstract,
"venue": paper.venue,
"year": paper.year,
"full_text": full_text,
"current_categories": paper.categories,
"current_relevance": paper.relevance,
}
payload = _make_full_text_payload(
paper,
full_text,
)
prompt = (
SYSTEM
@@ -202,14 +272,184 @@ contribution, and relevance to automotive radar when supported by the text.
),
)
data = json.loads(response.text)
data = _parse_json_text(response.text)
paper = _apply_result(paper, data)
paper.ai_analysis_level = "full_text"
return paper
def enrich_with_openai(paper, api_key, model):
# ============================================================
# Groq common prompt
# ============================================================
def _make_groq_prompt(payload, full_text=False):
if full_text:
instruction = """
You are given extracted full text from the paper.
Base the analysis primarily on the supplied full text.
Do not invent information that is not present in the supplied text.
If the extracted text appears incomplete or corrupted, be conservative.
The summary should describe the paper's main technical approach,
contribution, and relevance to automotive radar when supported by the text.
"""
else:
instruction = """
Analyze the supplied paper metadata only.
Do not infer details that are not present in the metadata.
"""
output_instruction = """
Return ONLY one valid JSON object.
The JSON object must have exactly these fields:
{
"relevance": 1,
"categories": ["Signal Processing"],
"summary": "한국어 요약",
"reason": "한국어 관련성 설명"
}
Rules:
- relevance must be an integer from 1 to 5
- categories must be a JSON array of strings
- summary must be written in Korean
- reason must be written in Korean
- do not use Markdown code fences
- do not include text before or after the JSON object
"""
return (
SYSTEM
+ instruction
+ output_instruction
+ "\n\nPaper data:\n"
+ json.dumps(payload, ensure_ascii=False)
)
# ============================================================
# Groq - metadata / abstract
# ============================================================
def enrich_with_groq(
paper,
api_key,
model,
):
if not api_key:
return paper
from groq import Groq
client = Groq(api_key=api_key)
payload = _make_payload(paper)
prompt = _make_groq_prompt(
payload,
full_text=False,
)
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "system",
"content": SYSTEM,
},
{
"role": "user",
"content": prompt,
},
],
temperature=0.1,
response_format={
"type": "json_object"
},
)
content = response.choices[0].message.content
data = _parse_json_text(content)
paper = _apply_result(paper, data)
paper.ai_analysis_level = "abstract"
return paper
# ============================================================
# Groq - full text
# ============================================================
def enrich_with_groq_full_text(
paper,
full_text,
api_key,
model,
):
if not api_key:
return paper
if not full_text or not full_text.strip():
return paper
from groq import Groq
client = Groq(api_key=api_key)
payload = _make_full_text_payload(
paper,
full_text,
)
prompt = _make_groq_prompt(
payload,
full_text=True,
)
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "system",
"content": SYSTEM,
},
{
"role": "user",
"content": prompt,
},
],
temperature=0.1,
response_format={
"type": "json_object"
},
)
content = response.choices[0].message.content
data = _parse_json_text(content)
paper = _apply_result(paper, data)
paper.ai_analysis_level = "full_text"
return paper
# ============================================================
# OpenAI - legacy / optional
# ============================================================
def enrich_with_openai(
paper,
api_key,
model,
):
if not api_key:
return paper
@@ -224,18 +464,15 @@ def enrich_with_openai(paper, api_key, model):
instructions=SYSTEM,
input=json.dumps(
payload,
ensure_ascii=False
ensure_ascii=False,
),
)
text = response.output_text.strip()
data = _parse_json_text(
response.output_text
)
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:].strip()
data = json.loads(text)
return _apply_result(paper, data)
return _apply_result(
paper,
data,
)