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
478 lines
11 KiB
Python
478 lines
11 KiB
Python
import json
|
|
import time
|
|
|
|
|
|
# ============================================================
|
|
# Gemini rate limiter
|
|
# ============================================================
|
|
|
|
_GEMINI_MIN_INTERVAL = 13.0
|
|
_GEMINI_LAST_CALL = 0.0
|
|
|
|
|
|
def _wait_for_gemini_rate_limit():
|
|
global _GEMINI_LAST_CALL
|
|
|
|
now = time.monotonic()
|
|
elapsed = now - _GEMINI_LAST_CALL
|
|
wait = _GEMINI_MIN_INTERVAL - elapsed
|
|
|
|
if wait > 0:
|
|
time.sleep(wait)
|
|
|
|
_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 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
|
|
- categories: technical categories
|
|
- summary: 1-2 concise Korean sentences
|
|
- reason: concise Korean explanation of why this paper is relevant
|
|
|
|
Preferred categories:
|
|
Antenna,
|
|
Waveform,
|
|
DOA,
|
|
MIMO,
|
|
Target Modeling,
|
|
Interference,
|
|
RF Impairment,
|
|
Imaging Radar,
|
|
Signal Processing,
|
|
Hardware,
|
|
Other.
|
|
'''
|
|
|
|
|
|
AI_RESPONSE_SCHEMA = {
|
|
"type": "object",
|
|
"properties": {
|
|
"relevance": {
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"maximum": 5,
|
|
},
|
|
"categories": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "string"
|
|
},
|
|
},
|
|
"summary": {
|
|
"type": "string"
|
|
},
|
|
"reason": {
|
|
"type": "string"
|
|
},
|
|
},
|
|
"required": [
|
|
"relevance",
|
|
"categories",
|
|
"summary",
|
|
"reason",
|
|
],
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Common helpers
|
|
# ============================================================
|
|
|
|
def _make_payload(paper):
|
|
return {
|
|
"title": paper.title,
|
|
"abstract": paper.abstract,
|
|
"venue": paper.venue,
|
|
"year": paper.year,
|
|
"current_categories": paper.categories,
|
|
"current_relevance": paper.relevance,
|
|
}
|
|
|
|
|
|
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,
|
|
min(
|
|
5,
|
|
int(data.get("relevance", paper.relevance))
|
|
)
|
|
)
|
|
|
|
if isinstance(data.get("categories"), list) and data["categories"]:
|
|
paper.categories = [
|
|
str(x)
|
|
for x in data["categories"][:5]
|
|
]
|
|
|
|
paper.summary = str(
|
|
data.get("summary", "")
|
|
).strip()
|
|
|
|
paper.ai_reason = str(
|
|
data.get("reason", "")
|
|
).strip()
|
|
|
|
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
|
|
|
|
from google import genai
|
|
from google.genai import types
|
|
|
|
client = genai.Client(api_key=api_key)
|
|
|
|
payload = _make_payload(paper)
|
|
|
|
prompt = (
|
|
SYSTEM
|
|
+ "\n\nPaper metadata:\n"
|
|
+ json.dumps(payload, ensure_ascii=False)
|
|
)
|
|
|
|
_wait_for_gemini_rate_limit()
|
|
|
|
response = client.models.generate_content(
|
|
model=model,
|
|
contents=prompt,
|
|
config=types.GenerateContentConfig(
|
|
temperature=0.1,
|
|
response_mime_type="application/json",
|
|
response_schema=AI_RESPONSE_SCHEMA,
|
|
),
|
|
)
|
|
|
|
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,
|
|
api_key,
|
|
model,
|
|
):
|
|
if not api_key:
|
|
return paper
|
|
|
|
if not full_text or not full_text.strip():
|
|
return paper
|
|
|
|
from google import genai
|
|
from google.genai import types
|
|
|
|
client = genai.Client(api_key=api_key)
|
|
|
|
payload = _make_full_text_payload(
|
|
paper,
|
|
full_text,
|
|
)
|
|
|
|
prompt = (
|
|
SYSTEM
|
|
+ """
|
|
|
|
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.
|
|
"""
|
|
+ "\n\nPaper data:\n"
|
|
+ json.dumps(payload, ensure_ascii=False)
|
|
)
|
|
|
|
_wait_for_gemini_rate_limit()
|
|
|
|
response = client.models.generate_content(
|
|
model=model,
|
|
contents=prompt,
|
|
config=types.GenerateContentConfig(
|
|
temperature=0.1,
|
|
response_mime_type="application/json",
|
|
response_schema=AI_RESPONSE_SCHEMA,
|
|
),
|
|
)
|
|
|
|
data = _parse_json_text(response.text)
|
|
|
|
paper = _apply_result(paper, data)
|
|
paper.ai_analysis_level = "full_text"
|
|
|
|
return paper
|
|
|
|
|
|
# ============================================================
|
|
# 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
|
|
|
|
from openai import OpenAI
|
|
|
|
client = OpenAI(api_key=api_key)
|
|
|
|
payload = _make_payload(paper)
|
|
|
|
response = client.responses.create(
|
|
model=model,
|
|
instructions=SYSTEM,
|
|
input=json.dumps(
|
|
payload,
|
|
ensure_ascii=False,
|
|
),
|
|
)
|
|
|
|
data = _parse_json_text(
|
|
response.output_text
|
|
)
|
|
|
|
return _apply_result(
|
|
paper,
|
|
data,
|
|
) |