omerehrlich commited on
Commit
ef6610c
·
1 Parent(s): 2b12c8d

Trim comments; server-key gate, usage log, store=True for retrievable summaries

Browse files
Files changed (1) hide show
  1. api.py +146 -8
api.py CHANGED
@@ -49,6 +49,47 @@ progress_map: Dict[str, Dict[str, int]] = {}
49
  # Add a lock for thread-safe access to progress_map
50
  progress_lock = threading.Lock()
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  # Initialize FastAPI app
53
  app = FastAPI(title="Wiki Revision Classifier API")
54
 
@@ -173,8 +214,7 @@ async def classify_article(payload: ClassifyPayload, background_tasks: Backgroun
173
  Start classification in the background and return immediately.
174
  Uses the flexible-linker pipeline + OpenAI Batch API (single batch).
175
  """
176
- # The classification pipeline runs on the user's own key — never the
177
- # server secret. The demo path doesn't hit /classify at all.
178
  api_key = _resolve_api_key(payload.api_key, allow_secret_fallback=False)
179
  model_name = payload.model or "gpt-5-mini"
180
 
@@ -588,10 +628,8 @@ class SummarizePayload(BaseModel):
588
  section_ids: Optional[List[int]] = None # grouped_idx values; None = all sections
589
  api_key: Optional[str] = None
590
  model: Optional[str] = "gpt-5.4"
591
- # Whether this summary may use the server's OPENAI_API_KEY secret. True
592
- # only for the demo pages (their summaries run on the provided secret key).
593
- # False for summaries on a user-generated page or an uploaded CSV, which
594
- # must run on the user's own key.
595
  use_server_key: bool = False
596
  # When the dataset lives only in the user's browser (CSV they uploaded),
597
  # the frontend filters rows itself and sends just the matching explanation
@@ -789,6 +827,7 @@ def generate_evolution_summary(
789
  client = openai.OpenAI(api_key=api_key)
790
  completion = client.chat.completions.create(
791
  model="gpt-5.4",
 
792
  messages=[
793
  {
794
  "role": "system",
@@ -802,9 +841,33 @@ def generate_evolution_summary(
802
  ],
803
  )
804
  text = completion.choices[0].message.content or ""
 
 
 
 
 
 
 
 
 
805
  return text.strip() or None
806
 
807
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
808
  @app.post("/summarize")
809
  async def summarize_edits(payload: SummarizePayload) -> Dict[str, Any]:
810
  """
@@ -812,6 +875,28 @@ async def summarize_edits(payload: SummarizePayload) -> Dict[str, Any]:
812
  section-group) cell using a fast OpenAI model. Used by interactive chart
813
  drill-downs.
814
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
815
  # Normalize the label set. A combined request (by-section "All edit types"
816
  # cell) carries multiple labels; a single-label request carries just one.
817
  # `combined_labels` is the deduped, sorted set we match/summarize over;
@@ -1063,10 +1148,9 @@ async def summarize_edits(payload: SummarizePayload) -> Dict[str, Any]:
1063
  + "\n\n".join(f"- {s}" for s in snippets_for_prompt)
1064
  )
1065
 
 
1066
  try:
1067
  import openai
1068
- # Demo summaries fall back to the server secret; summaries on a
1069
- # user-generated page or uploaded CSV require the user's own key.
1070
  client = openai.OpenAI(
1071
  api_key=_resolve_api_key(
1072
  payload.api_key, allow_secret_fallback=payload.use_server_key
@@ -1074,6 +1158,7 @@ async def summarize_edits(payload: SummarizePayload) -> Dict[str, Any]:
1074
  )
1075
  completion = client.chat.completions.create(
1076
  model="gpt-5.4",
 
1077
  messages=[
1078
  {
1079
  "role": "system",
@@ -1087,6 +1172,23 @@ async def summarize_edits(payload: SummarizePayload) -> Dict[str, Any]:
1087
  ],
1088
  )
1089
  summary_text = completion.choices[0].message.content or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
  except Exception as e:
1091
  raise HTTPException(status_code=502, detail=f"OpenAI request failed: {e}")
1092
 
@@ -1125,6 +1227,42 @@ async def get_progress(article: str) -> Dict[str, Any]:
1125
  "error": article_progress.get("error"),
1126
  }
1127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1128
  # Serve the built React app at "/".
1129
  # This must be registered AFTER all API routes so /prepare, /classify, /progress, /results,
1130
  # /files still take precedence.
 
49
  # Add a lock for thread-safe access to progress_map
50
  progress_lock = threading.Lock()
51
 
52
+ USAGE_LOG_PATH = os.path.join("visualizations", "openai_usage_log.jsonl")
53
+ _usage_log_lock = threading.Lock()
54
+
55
+
56
+ def _log_openai_call(
57
+ endpoint: str,
58
+ model: str,
59
+ article: Optional[str],
60
+ used_server_key: bool,
61
+ prompt_chars: int,
62
+ response_chars: int,
63
+ usage: Optional[Dict[str, Any]] = None,
64
+ extra: Optional[Dict[str, Any]] = None,
65
+ ) -> None:
66
+ """Append one line describing an OpenAI call to the log. Best-effort:
67
+ failures are swallowed so logging can never break the request."""
68
+ record: Dict[str, Any] = {
69
+ "ts": _utc_now_iso(),
70
+ "endpoint": endpoint,
71
+ "model": model,
72
+ "article": article,
73
+ "used_server_key": bool(used_server_key),
74
+ "prompt_chars": int(prompt_chars),
75
+ "response_chars": int(response_chars),
76
+ }
77
+ if usage:
78
+ record["usage"] = usage
79
+ if extra:
80
+ record.update(extra)
81
+ try:
82
+ with _usage_log_lock:
83
+ with open(USAGE_LOG_PATH, "a", encoding="utf-8") as f:
84
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
85
+ except Exception as log_err: # pragma: no cover - logging must never throw
86
+ print(f"[warn] failed to write usage log: {log_err}")
87
+
88
+
89
+ def _utc_now_iso() -> str:
90
+ from datetime import datetime, timezone
91
+ return datetime.now(timezone.utc).isoformat()
92
+
93
  # Initialize FastAPI app
94
  app = FastAPI(title="Wiki Revision Classifier API")
95
 
 
214
  Start classification in the background and return immediately.
215
  Uses the flexible-linker pipeline + OpenAI Batch API (single batch).
216
  """
217
+ # Classification always runs on the user's own key.
 
218
  api_key = _resolve_api_key(payload.api_key, allow_secret_fallback=False)
219
  model_name = payload.model or "gpt-5-mini"
220
 
 
628
  section_ids: Optional[List[int]] = None # grouped_idx values; None = all sections
629
  api_key: Optional[str] = None
630
  model: Optional[str] = "gpt-5.4"
631
+ # Set only for the built-in demo pages; other summaries run on the user's
632
+ # own key.
 
 
633
  use_server_key: bool = False
634
  # When the dataset lives only in the user's browser (CSV they uploaded),
635
  # the frontend filters rows itself and sends just the matching explanation
 
827
  client = openai.OpenAI(api_key=api_key)
828
  completion = client.chat.completions.create(
829
  model="gpt-5.4",
830
+ store=True,
831
  messages=[
832
  {
833
  "role": "system",
 
841
  ],
842
  )
843
  text = completion.choices[0].message.content or ""
844
+ _log_openai_call(
845
+ endpoint="evolution",
846
+ model="gpt-5.4",
847
+ article=None,
848
+ used_server_key=False,
849
+ prompt_chars=len(user_prompt),
850
+ response_chars=len(text),
851
+ usage=_usage_dict(completion),
852
+ )
853
  return text.strip() or None
854
 
855
 
856
+ def _usage_dict(completion: Any) -> Optional[Dict[str, Any]]:
857
+ """Pull token counts out of a chat completion response, if present."""
858
+ usage = getattr(completion, "usage", None)
859
+ if usage is None:
860
+ return None
861
+ try:
862
+ return {
863
+ "prompt_tokens": getattr(usage, "prompt_tokens", None),
864
+ "completion_tokens": getattr(usage, "completion_tokens", None),
865
+ "total_tokens": getattr(usage, "total_tokens", None),
866
+ }
867
+ except Exception:
868
+ return None
869
+
870
+
871
  @app.post("/summarize")
872
  async def summarize_edits(payload: SummarizePayload) -> Dict[str, Any]:
873
  """
 
875
  section-group) cell using a fast OpenAI model. Used by interactive chart
876
  drill-downs.
877
  """
878
+ # The server key is limited to demo articles served from the bundled
879
+ # dataset.
880
+ if payload.use_server_key:
881
+ if payload.article not in DEMOS:
882
+ raise HTTPException(
883
+ status_code=403,
884
+ detail=(
885
+ "The server API key may only be used for built-in demo "
886
+ f"articles. '{payload.article}' is not a demo — supply your "
887
+ "own OpenAI key for this request."
888
+ ),
889
+ )
890
+ if payload.explanations is not None:
891
+ raise HTTPException(
892
+ status_code=403,
893
+ detail=(
894
+ "The server API key may not be used with client-supplied "
895
+ "explanations. Demo summaries are computed from the server's "
896
+ "bundled dataset only."
897
+ ),
898
+ )
899
+
900
  # Normalize the label set. A combined request (by-section "All edit types"
901
  # cell) carries multiple labels; a single-label request carries just one.
902
  # `combined_labels` is the deduped, sorted set we match/summarize over;
 
1148
  + "\n\n".join(f"- {s}" for s in snippets_for_prompt)
1149
  )
1150
 
1151
+ billed_server_key = payload.use_server_key and not (payload.api_key or "").strip()
1152
  try:
1153
  import openai
 
 
1154
  client = openai.OpenAI(
1155
  api_key=_resolve_api_key(
1156
  payload.api_key, allow_secret_fallback=payload.use_server_key
 
1158
  )
1159
  completion = client.chat.completions.create(
1160
  model="gpt-5.4",
1161
+ store=True,
1162
  messages=[
1163
  {
1164
  "role": "system",
 
1172
  ],
1173
  )
1174
  summary_text = completion.choices[0].message.content or ""
1175
+ _log_openai_call(
1176
+ endpoint="summarize",
1177
+ model="gpt-5.4",
1178
+ article=payload.article,
1179
+ used_server_key=billed_server_key,
1180
+ prompt_chars=len(user_prompt),
1181
+ response_chars=len(summary_text),
1182
+ usage=_usage_dict(completion),
1183
+ extra={
1184
+ "labels": combined_labels,
1185
+ "period_key": payload.period_key,
1186
+ "snippet_count": len(snippets_for_prompt),
1187
+ "client_supplied_explanations": client_snippets is not None,
1188
+ },
1189
+ )
1190
+ except HTTPException:
1191
+ raise
1192
  except Exception as e:
1193
  raise HTTPException(status_code=502, detail=f"OpenAI request failed: {e}")
1194
 
 
1227
  "error": article_progress.get("error"),
1228
  }
1229
 
1230
+
1231
+ @app.get("/usage-log")
1232
+ async def get_usage_log(token: str, limit: int = 500) -> Dict[str, Any]:
1233
+ """Return recent usage-log entries. Requires a valid token."""
1234
+ expected = (
1235
+ os.environ.get("USAGE_LOG_TOKEN", "").strip()
1236
+ or os.environ.get("OPENAI_API_KEY", "").strip()
1237
+ )
1238
+ if not expected or token.strip() != expected:
1239
+ raise HTTPException(status_code=403, detail="Invalid or missing token.")
1240
+
1241
+ if not os.path.exists(USAGE_LOG_PATH):
1242
+ return {"entries": [], "total_entries": 0, "server_key_calls": 0}
1243
+
1244
+ entries: List[Dict[str, Any]] = []
1245
+ server_key_calls = 0
1246
+ with _usage_log_lock:
1247
+ with open(USAGE_LOG_PATH, "r", encoding="utf-8") as f:
1248
+ for line in f:
1249
+ line = line.strip()
1250
+ if not line:
1251
+ continue
1252
+ try:
1253
+ rec = json.loads(line)
1254
+ except Exception:
1255
+ continue
1256
+ entries.append(rec)
1257
+ if rec.get("used_server_key"):
1258
+ server_key_calls += 1
1259
+
1260
+ return {
1261
+ "total_entries": len(entries),
1262
+ "server_key_calls": server_key_calls,
1263
+ "entries": entries[-max(0, limit):],
1264
+ }
1265
+
1266
  # Serve the built React app at "/".
1267
  # This must be registered AFTER all API routes so /prepare, /classify, /progress, /results,
1268
  # /files still take precedence.