Files changed (1) hide show
  1. app.py +82 -14
app.py CHANGED
@@ -7,6 +7,8 @@ from __future__ import annotations
7
  import json
8
  import os
9
  import re
 
 
10
  from pathlib import Path
11
 
12
  import gradio as gr
@@ -17,6 +19,8 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError
17
  from sentence_transformers import SentenceTransformer
18
  from retrieval_zerogpu import OriginRetrieval, MODEL_ID
19
 
 
 
20
  ROOT = Path(__file__).resolve().parent
21
  DATA = ROOT / 'assets/projects_clean.csv'
22
  VECTORS = ROOT / 'assets/project_vectors.npz'
@@ -129,22 +133,86 @@ def enhance(title: str, description: str, language: str, search_payload: dict):
129
  request = {'proposed_idea':{'title':idea.title,'description':idea.description,'language':lang},
130
  'retrieved_projects':context}
131
  model_id = os.getenv('ORIGIN_GEMINI_MODEL', 'gemini-2.5-flash')
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  try:
133
- client = genai.Client(api_key=key, http_options=types.HttpOptions(timeout=60000,
134
- retry_options=types.HttpRetryOptions(attempts=1)))
135
- response = client.models.generate_content(
136
- model=model_id, contents=json.dumps(request, ensure_ascii=False),
137
- config=types.GenerateContentConfig(
138
- system_instruction=SYSTEM_INSTRUCTIONS,
139
- response_mime_type='application/json',
140
- response_json_schema=GeneratedAnalysis.model_json_schema(),
141
- temperature=0.2, max_output_tokens=4096))
142
- if not response.text:
143
- raise ValueError('empty model response')
144
- generated = GeneratedAnalysis.model_validate_json(response.text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  except Exception as exc:
146
- # Avoid exposing prompts, SDK exceptions or secrets to public users.
147
- raise gr.Error('Gemini analysis unavailable or invalid. Please retry later.') from None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
  # Recreate response from vetted local CSV and validated generated fields.
150
  ranks = [{'rank':i+1, 'project_id':pid} for i,pid in enumerate(ids)]
 
7
  import json
8
  import os
9
  import re
10
+ import logging
11
+ import time
12
  from pathlib import Path
13
 
14
  import gradio as gr
 
19
  from sentence_transformers import SentenceTransformer
20
  from retrieval_zerogpu import OriginRetrieval, MODEL_ID
21
 
22
+ logger = logging.getLogger('origin.gemini')
23
+
24
  ROOT = Path(__file__).resolve().parent
25
  DATA = ROOT / 'assets/projects_clean.csv'
26
  VECTORS = ROOT / 'assets/project_vectors.npz'
 
133
  request = {'proposed_idea':{'title':idea.title,'description':idea.description,'language':lang},
134
  'retrieved_projects':context}
135
  model_id = os.getenv('ORIGIN_GEMINI_MODEL', 'gemini-2.5-flash')
136
+ # Use Gemini's supported Pydantic structured-output mode. For Gemini 2.5 Flash,
137
+ # disabling thinking prevents a large hidden token budget consuming the output
138
+ # budget, which can otherwise produce empty/truncated JSON responses.
139
+ config_kwargs = dict(
140
+ system_instruction=SYSTEM_INSTRUCTIONS,
141
+ response_mime_type='application/json',
142
+ response_schema=GeneratedAnalysis,
143
+ temperature=0.2,
144
+ max_output_tokens=4096,
145
+ )
146
+ if model_id.startswith('gemini-2.5-flash'):
147
+ config_kwargs['thinking_config'] = types.ThinkingConfig(thinking_budget=0)
148
+
149
  try:
150
+ # 60 seconds per request; avoid SDK retries that can consume extra quota.
151
+ client = genai.Client(
152
+ api_key=key,
153
+ http_options=types.HttpOptions(
154
+ timeout=60000,
155
+ retry_options=types.HttpRetryOptions(attempts=1),
156
+ ),
157
+ )
158
+ for attempt in range(2):
159
+ try:
160
+ response = client.models.generate_content(
161
+ model=model_id,
162
+ contents=json.dumps(request, ensure_ascii=False),
163
+ config=types.GenerateContentConfig(**config_kwargs),
164
+ )
165
+ break
166
+ except Exception as exc:
167
+ code = getattr(exc, 'code', None)
168
+ # Retry only a temporarily unavailable server; NEVER retry
169
+ # invalid API keys, bad requests, or exhausted Gemini quota.
170
+ if attempt == 0 and code in (502, 503, 504):
171
+ logger.warning('origin.gemini_retry code=%s model=%s', code, model_id)
172
+ time.sleep(2)
173
+ continue
174
+ raise
175
+
176
+ # The SDK can return a validated Pydantic instance directly.
177
+ if isinstance(getattr(response, 'parsed', None), GeneratedAnalysis):
178
+ generated = response.parsed
179
+ elif getattr(response, 'text', None):
180
+ generated = GeneratedAnalysis.model_validate_json(response.text)
181
+ else:
182
+ # Log only finish-reason name; never log candidate content.
183
+ candidates = getattr(response, 'candidates', None) or []
184
+ reason = getattr(candidates[0], 'finish_reason', None) if candidates else None
185
+ logger.warning('origin.gemini_empty_response finish_reason=%s model=%s',
186
+ getattr(reason, 'name', str(reason)) if reason else 'unknown', model_id)
187
+ raise ValueError('empty_gemini_response')
188
  except Exception as exc:
189
+ # Gemini SDK exceptions often contain private request details. Log only
190
+ # safe status and exception TYPE, never exception message or prompt.
191
+ code = getattr(exc, 'code', None)
192
+ if code == 429:
193
+ category = 'quota_or_rate_limit'
194
+ elif code in (401, 403):
195
+ category = 'credentials_or_permissions'
196
+ elif code == 404:
197
+ category = 'model_not_found'
198
+ elif code == 400:
199
+ category = 'invalid_request_or_schema'
200
+ elif code in (500, 502, 503, 504):
201
+ category = 'temporary_api_failure'
202
+ elif isinstance(exc, ValidationError):
203
+ category = 'generated_json_validation'
204
+ elif isinstance(exc, ValueError):
205
+ category = 'empty_or_invalid_json'
206
+ else:
207
+ category = 'sdk_or_network_error'
208
+ logger.error(
209
+ 'origin.gemini_failed category=%s status=%s type=%s model=%s',
210
+ category, code if isinstance(code, int) else 'n/a',
211
+ type(exc).__name__, model_id,
212
+ )
213
+ # /search remains independent, and the real React frontend already
214
+ # preserves search results when /enhance fails.
215
+ raise gr.Error('Gemini analysis unavailable. Search results are still available.') from None
216
 
217
  # Recreate response from vetted local CSV and validated generated fields.
218
  ranks = [{'rank':i+1, 'project_id':pid} for i,pid in enumerate(ids)]