Spaces:
Sleeping
Sleeping
Commit ·
ef6610c
1
Parent(s): 2b12c8d
Trim comments; server-key gate, usage log, store=True for retrievable summaries
Browse files
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 |
-
#
|
| 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 |
-
#
|
| 592 |
-
#
|
| 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.
|