ifieryarrows commited on
Commit
9c39ba1
·
verified ·
1 Parent(s): 6aa5580

Sync from GitHub (tests passed)

Browse files
Files changed (2) hide show
  1. adapters/db/lock.py +12 -5
  2. 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
- result = session.execute(
73
- text("SELECT pg_advisory_unlock(:lock_id)"),
74
- {"lock_id": lock_id}
75
- ).scalar()
 
 
 
 
76
 
77
  if result:
78
  logger.info(f"Advisory lock released: {lock_key}")
79
  else:
80
- logger.warning(f"Advisory lock release failed (not held?): {lock_key}")
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
- Supports markdown fenced output in relaxed fallback mode.
 
 
 
 
 
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
- if not normalized.startswith("["):
518
- first = normalized.find("[")
519
- last = normalized.rfind("]")
520
- if first != -1 and last != -1 and last > first:
521
- normalized = normalized[first:last + 1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(strict_schema: bool, *, max_tokens: int) -> dict[str, Any]:
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
- async def _request_with_provider_fallback(*, max_tokens: int) -> dict[str, Any]:
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 _request_with_provider_fallback(max_tokens=LLM_SCORING_MAX_TOKENS_RETRY)
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(strict_schema: bool, *, max_tokens: int) -> dict[str, Any]:
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 _request_with_provider_fallback(max_tokens=LLM_SCORING_MAX_TOKENS_PRIMARY)
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 _request_with_provider_fallback(max_tokens=LLM_SCORING_MAX_TOKENS_RETRY)
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)