Spaces:
Running
Running
Sync from GitHub (tests passed)
Browse files- adapters/db/lock.py +12 -5
- app/ai_engine.py +46 -45
adapters/db/lock.py
CHANGED
|
@@ -66,18 +66,25 @@ def release_lock(session: Session, lock_key: str) -> bool:
|
|
| 66 |
|
| 67 |
Usually not needed - lock auto-releases on session close.
|
| 68 |
Use this for early release if pipeline completes before session ends.
|
|
|
|
|
|
|
|
|
|
| 69 |
"""
|
| 70 |
lock_id = _lock_key_to_id(lock_key)
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
if result:
|
| 78 |
logger.info(f"Advisory lock released: {lock_key}")
|
| 79 |
else:
|
| 80 |
-
logger.
|
| 81 |
|
| 82 |
return bool(result)
|
| 83 |
|
|
|
|
| 66 |
|
| 67 |
Usually not needed - lock auto-releases on session close.
|
| 68 |
Use this for early release if pipeline completes before session ends.
|
| 69 |
+
|
| 70 |
+
Note: Returns False if lock was already released (e.g., connection recycled).
|
| 71 |
+
This is expected behavior, not an error.
|
| 72 |
"""
|
| 73 |
lock_id = _lock_key_to_id(lock_key)
|
| 74 |
|
| 75 |
+
try:
|
| 76 |
+
result = session.execute(
|
| 77 |
+
text("SELECT pg_advisory_unlock(:lock_id)"),
|
| 78 |
+
{"lock_id": lock_id}
|
| 79 |
+
).scalar()
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.debug(f"Advisory lock release query failed (connection may have been recycled): {lock_key} - {e}")
|
| 82 |
+
return False
|
| 83 |
|
| 84 |
if result:
|
| 85 |
logger.info(f"Advisory lock released: {lock_key}")
|
| 86 |
else:
|
| 87 |
+
logger.debug(f"Advisory lock already released (connection recycled): {lock_key}")
|
| 88 |
|
| 89 |
return bool(result)
|
| 90 |
|
app/ai_engine.py
CHANGED
|
@@ -500,9 +500,16 @@ def _extract_finish_reason(data: dict[str, Any]) -> str:
|
|
| 500 |
def _clean_json_content(content: str) -> str:
|
| 501 |
"""
|
| 502 |
Normalize model text into parseable JSON content.
|
| 503 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
"""
|
| 505 |
normalized = content.strip()
|
|
|
|
|
|
|
| 506 |
if normalized.startswith("```"):
|
| 507 |
lines = normalized.splitlines()
|
| 508 |
if lines and lines[0].startswith("```"):
|
|
@@ -514,11 +521,36 @@ def _clean_json_content(content: str) -> str:
|
|
| 514 |
if normalized.startswith("json"):
|
| 515 |
normalized = normalized[4:].strip()
|
| 516 |
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
|
| 523 |
return normalized
|
| 524 |
|
|
@@ -622,7 +654,7 @@ async def score_batch_with_llm(
|
|
| 622 |
)
|
| 623 |
model_name = settings.resolved_scoring_model
|
| 624 |
|
| 625 |
-
async def _request_scoring(
|
| 626 |
request_kwargs: dict[str, Any] = {
|
| 627 |
"api_key": settings.openrouter_api_key,
|
| 628 |
"model": model_name,
|
|
@@ -638,11 +670,9 @@ async def score_batch_with_llm(
|
|
| 638 |
"fallback_models": settings.openrouter_fallback_models_list,
|
| 639 |
"referer": "https://copper-mind.vercel.app",
|
| 640 |
"title": "CopperMind Sentiment Analysis",
|
|
|
|
| 641 |
"extra_payload": {"reasoning": {"exclude": True}},
|
| 642 |
}
|
| 643 |
-
if strict_schema:
|
| 644 |
-
request_kwargs["response_format"] = LLM_SCORING_RESPONSE_FORMAT
|
| 645 |
-
request_kwargs["provider"] = LLM_SCORING_PROVIDER_OPTIONS
|
| 646 |
return await create_chat_completion(**request_kwargs)
|
| 647 |
|
| 648 |
async def _repair_json_response(malformed_content: str) -> str:
|
|
@@ -689,21 +719,7 @@ async def score_batch_with_llm(
|
|
| 689 |
json_repair_used=repair_used,
|
| 690 |
)
|
| 691 |
|
| 692 |
-
|
| 693 |
-
try:
|
| 694 |
-
return await _request_scoring(strict_schema=True, max_tokens=max_tokens)
|
| 695 |
-
except OpenRouterError as exc:
|
| 696 |
-
message = str(exc).lower()
|
| 697 |
-
# Some free providers return 404 when strict provider parameters are not supported.
|
| 698 |
-
if exc.status_code == 404 and "no endpoints found" in message:
|
| 699 |
-
logger.warning(
|
| 700 |
-
"Structured scoring request unsupported by current provider routing. "
|
| 701 |
-
"Retrying without strict response_format/provider constraints."
|
| 702 |
-
)
|
| 703 |
-
return await _request_scoring(strict_schema=False, max_tokens=max_tokens)
|
| 704 |
-
raise
|
| 705 |
-
|
| 706 |
-
data = await _request_with_provider_fallback(max_tokens=LLM_SCORING_MAX_TOKENS_PRIMARY)
|
| 707 |
|
| 708 |
content = _extract_chat_message_content(data)
|
| 709 |
if not content:
|
|
@@ -714,7 +730,7 @@ async def score_batch_with_llm(
|
|
| 714 |
"retrying with max_tokens=%s",
|
| 715 |
LLM_SCORING_MAX_TOKENS_RETRY,
|
| 716 |
)
|
| 717 |
-
data = await
|
| 718 |
content = _extract_chat_message_content(data)
|
| 719 |
if not content:
|
| 720 |
raise OpenRouterError(
|
|
@@ -1020,7 +1036,7 @@ async def _score_subset_with_model_v2(
|
|
| 1020 |
expected_ids = [int(article["id"]) for article in articles]
|
| 1021 |
user_prompt = _build_llm_v2_user_prompt(articles, horizon_days=horizon_days)
|
| 1022 |
|
| 1023 |
-
async def _request(
|
| 1024 |
request_kwargs: dict[str, Any] = {
|
| 1025 |
"api_key": settings.openrouter_api_key,
|
| 1026 |
"model": model_name,
|
|
@@ -1036,29 +1052,14 @@ async def _score_subset_with_model_v2(
|
|
| 1036 |
"fallback_models": settings.openrouter_fallback_models_list,
|
| 1037 |
"referer": "https://copper-mind.vercel.app",
|
| 1038 |
"title": "CopperMind Sentiment Analysis V2",
|
|
|
|
| 1039 |
"extra_payload": {"reasoning": {"exclude": True}},
|
| 1040 |
}
|
| 1041 |
-
if strict_schema:
|
| 1042 |
-
request_kwargs["response_format"] = LLM_SCORING_RESPONSE_FORMAT_V2
|
| 1043 |
-
request_kwargs["provider"] = LLM_SCORING_PROVIDER_OPTIONS
|
| 1044 |
return await create_chat_completion(**request_kwargs)
|
| 1045 |
|
| 1046 |
-
async def _request_with_provider_fallback(*, max_tokens: int) -> dict[str, Any]:
|
| 1047 |
-
try:
|
| 1048 |
-
return await _request(strict_schema=True, max_tokens=max_tokens)
|
| 1049 |
-
except OpenRouterError as exc:
|
| 1050 |
-
message = str(exc).lower()
|
| 1051 |
-
if exc.status_code == 404 and "no endpoints found" in message:
|
| 1052 |
-
logger.warning(
|
| 1053 |
-
"V2 structured scoring unsupported by provider route for model=%s. Retrying relaxed mode.",
|
| 1054 |
-
model_name,
|
| 1055 |
-
)
|
| 1056 |
-
return await _request(strict_schema=False, max_tokens=max_tokens)
|
| 1057 |
-
raise
|
| 1058 |
-
|
| 1059 |
parse_fail_count = 0
|
| 1060 |
try:
|
| 1061 |
-
data = await
|
| 1062 |
except Exception:
|
| 1063 |
return {}, expected_ids, len(expected_ids)
|
| 1064 |
|
|
@@ -1066,7 +1067,7 @@ async def _score_subset_with_model_v2(
|
|
| 1066 |
if not content:
|
| 1067 |
finish_reason = _extract_finish_reason(data)
|
| 1068 |
if finish_reason == "length":
|
| 1069 |
-
data = await
|
| 1070 |
content = _extract_chat_message_content(data)
|
| 1071 |
if not content:
|
| 1072 |
return {}, expected_ids, len(expected_ids)
|
|
|
|
| 500 |
def _clean_json_content(content: str) -> str:
|
| 501 |
"""
|
| 502 |
Normalize model text into parseable JSON content.
|
| 503 |
+
|
| 504 |
+
Handles common LLM output quirks:
|
| 505 |
+
- Markdown fenced code blocks (```json ... ```)
|
| 506 |
+
- Wrapped objects like {"results": [...]} or {"scores": [...]}
|
| 507 |
+
- Thinking/reasoning preamble before JSON
|
| 508 |
+
- Raw JSON arrays
|
| 509 |
"""
|
| 510 |
normalized = content.strip()
|
| 511 |
+
|
| 512 |
+
# Strip markdown code fences
|
| 513 |
if normalized.startswith("```"):
|
| 514 |
lines = normalized.splitlines()
|
| 515 |
if lines and lines[0].startswith("```"):
|
|
|
|
| 521 |
if normalized.startswith("json"):
|
| 522 |
normalized = normalized[4:].strip()
|
| 523 |
|
| 524 |
+
# Already a JSON array — return as-is
|
| 525 |
+
if normalized.startswith("["):
|
| 526 |
+
return normalized
|
| 527 |
+
|
| 528 |
+
# Wrapped object: {"results": [...], ...} or {"scores": [...], ...}
|
| 529 |
+
if normalized.startswith("{"):
|
| 530 |
+
try:
|
| 531 |
+
import json as _json
|
| 532 |
+
obj = _json.loads(normalized)
|
| 533 |
+
if isinstance(obj, dict):
|
| 534 |
+
# Find the first list value
|
| 535 |
+
for v in obj.values():
|
| 536 |
+
if isinstance(v, list):
|
| 537 |
+
return _json.dumps(v)
|
| 538 |
+
# Single object — wrap in array
|
| 539 |
+
return _json.dumps([obj])
|
| 540 |
+
except Exception:
|
| 541 |
+
pass
|
| 542 |
+
|
| 543 |
+
# Preamble text before JSON — find the array
|
| 544 |
+
first = normalized.find("[")
|
| 545 |
+
last = normalized.rfind("]")
|
| 546 |
+
if first != -1 and last != -1 and last > first:
|
| 547 |
+
return normalized[first:last + 1]
|
| 548 |
+
|
| 549 |
+
# Last resort — try to find an object
|
| 550 |
+
first_obj = normalized.find("{")
|
| 551 |
+
last_obj = normalized.rfind("}")
|
| 552 |
+
if first_obj != -1 and last_obj != -1 and last_obj > first_obj:
|
| 553 |
+
return normalized[first_obj:last_obj + 1]
|
| 554 |
|
| 555 |
return normalized
|
| 556 |
|
|
|
|
| 654 |
)
|
| 655 |
model_name = settings.resolved_scoring_model
|
| 656 |
|
| 657 |
+
async def _request_scoring(*, max_tokens: int) -> dict[str, Any]:
|
| 658 |
request_kwargs: dict[str, Any] = {
|
| 659 |
"api_key": settings.openrouter_api_key,
|
| 660 |
"model": model_name,
|
|
|
|
| 670 |
"fallback_models": settings.openrouter_fallback_models_list,
|
| 671 |
"referer": "https://copper-mind.vercel.app",
|
| 672 |
"title": "CopperMind Sentiment Analysis",
|
| 673 |
+
"response_format": LLM_SCORING_RESPONSE_FORMAT,
|
| 674 |
"extra_payload": {"reasoning": {"exclude": True}},
|
| 675 |
}
|
|
|
|
|
|
|
|
|
|
| 676 |
return await create_chat_completion(**request_kwargs)
|
| 677 |
|
| 678 |
async def _repair_json_response(malformed_content: str) -> str:
|
|
|
|
| 719 |
json_repair_used=repair_used,
|
| 720 |
)
|
| 721 |
|
| 722 |
+
data = await _request_scoring(max_tokens=LLM_SCORING_MAX_TOKENS_PRIMARY)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
|
| 724 |
content = _extract_chat_message_content(data)
|
| 725 |
if not content:
|
|
|
|
| 730 |
"retrying with max_tokens=%s",
|
| 731 |
LLM_SCORING_MAX_TOKENS_RETRY,
|
| 732 |
)
|
| 733 |
+
data = await _request_scoring(max_tokens=LLM_SCORING_MAX_TOKENS_RETRY)
|
| 734 |
content = _extract_chat_message_content(data)
|
| 735 |
if not content:
|
| 736 |
raise OpenRouterError(
|
|
|
|
| 1036 |
expected_ids = [int(article["id"]) for article in articles]
|
| 1037 |
user_prompt = _build_llm_v2_user_prompt(articles, horizon_days=horizon_days)
|
| 1038 |
|
| 1039 |
+
async def _request(*, max_tokens: int) -> dict[str, Any]:
|
| 1040 |
request_kwargs: dict[str, Any] = {
|
| 1041 |
"api_key": settings.openrouter_api_key,
|
| 1042 |
"model": model_name,
|
|
|
|
| 1052 |
"fallback_models": settings.openrouter_fallback_models_list,
|
| 1053 |
"referer": "https://copper-mind.vercel.app",
|
| 1054 |
"title": "CopperMind Sentiment Analysis V2",
|
| 1055 |
+
"response_format": LLM_SCORING_RESPONSE_FORMAT_V2,
|
| 1056 |
"extra_payload": {"reasoning": {"exclude": True}},
|
| 1057 |
}
|
|
|
|
|
|
|
|
|
|
| 1058 |
return await create_chat_completion(**request_kwargs)
|
| 1059 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
parse_fail_count = 0
|
| 1061 |
try:
|
| 1062 |
+
data = await _request(max_tokens=LLM_SCORING_MAX_TOKENS_PRIMARY)
|
| 1063 |
except Exception:
|
| 1064 |
return {}, expected_ids, len(expected_ids)
|
| 1065 |
|
|
|
|
| 1067 |
if not content:
|
| 1068 |
finish_reason = _extract_finish_reason(data)
|
| 1069 |
if finish_reason == "length":
|
| 1070 |
+
data = await _request(max_tokens=LLM_SCORING_MAX_TOKENS_RETRY)
|
| 1071 |
content = _extract_chat_message_content(data)
|
| 1072 |
if not content:
|
| 1073 |
return {}, expected_ids, len(expected_ids)
|