ifieryarrows commited on
Commit
1e6ab4d
·
verified ·
1 Parent(s): 8e48995

Sync from GitHub (tests passed)

Browse files
Files changed (6) hide show
  1. Dockerfile +5 -2
  2. app/ai_engine.py +210 -140
  3. app/commentary.py +114 -76
  4. app/models.py +1 -1
  5. app/openrouter_client.py +201 -0
  6. app/settings.py +51 -0
Dockerfile CHANGED
@@ -35,7 +35,10 @@ EXPOSE 7860
35
  # Environment
36
  ENV PYTHONUNBUFFERED=1 \
37
  PYTHONPATH=/code \
38
- REDIS_URL=redis://127.0.0.1:6379/0
 
 
 
39
 
40
  # Run supervisord (manages redis + api + worker)
41
- CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
 
35
  # Environment
36
  ENV PYTHONUNBUFFERED=1 \
37
  PYTHONPATH=/code \
38
+ REDIS_URL=redis://127.0.0.1:6379/0 \
39
+ HF_HUB_DISABLE_PROGRESS_BARS=1 \
40
+ TRANSFORMERS_VERBOSITY=error \
41
+ TRANSFORMERS_NO_ADVISORY_WARNINGS=1
42
 
43
  # Run supervisord (manages redis + api + worker)
44
+ CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
app/ai_engine.py CHANGED
@@ -2,7 +2,7 @@
2
  AI Engine: LLM sentiment scoring (with FinBERT fallback) + XGBoost training.
3
 
4
  Sentiment Analysis:
5
- Primary: Gemini LLM with copper-specific context (1M token batch)
6
  Fallback: FinBERT for generic financial sentiment
7
 
8
  Usage:
@@ -15,16 +15,11 @@ import argparse
15
  import json
16
  import logging
17
  import os
18
- import time
19
  from datetime import datetime, timedelta, timezone
20
  from pathlib import Path
21
  from typing import Any, Optional
22
 
23
- # Suppress httpx request logging to prevent API keys in URLs from appearing in logs
24
- logging.getLogger("httpx").setLevel(logging.WARNING)
25
-
26
- import httpx
27
-
28
  import numpy as np
29
  import pandas as pd
30
  from sqlalchemy import func
@@ -38,6 +33,7 @@ from app.settings import get_settings
38
  from app.features import build_feature_matrix, get_feature_descriptions
39
  from app.lock import pipeline_lock
40
  from app.async_bridge import run_async_from_sync
 
41
 
42
  logging.basicConfig(
43
  level=logging.INFO,
@@ -123,11 +119,16 @@ def _log_finbert_output_once(raw_output: Any) -> None:
123
  )
124
  _FINBERT_OUTPUT_LOGGED = True
125
 
 
126
  def get_finbert_pipeline():
127
  """
128
  Load FinBERT model pipeline.
129
  Lazy loading to avoid import overhead when not needed.
130
  """
 
 
 
 
131
  from transformers import pipeline, AutoModelForSequenceClassification, AutoTokenizer
132
 
133
  model_name = "ProsusAI/finbert"
@@ -214,7 +215,7 @@ def score_text_with_finbert(
214
 
215
 
216
  # =============================================================================
217
- # LLM Sentiment Scoring (Primary - Gemini)
218
  # =============================================================================
219
 
220
  # Copper-specific system prompt for LLM sentiment analysis
@@ -276,125 +277,174 @@ Rules:
276
  - Use standard decimals (e.g., -0.4, 0.15, 1.0); no NaN, no scientific notation."""
277
 
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  async def score_batch_with_llm(
280
  articles: list[dict],
281
  ) -> list[dict]:
282
  """
283
- Score a batch of articles using LLM (Gemini via OpenRouter).
284
-
285
- Args:
286
- articles: List of dicts with 'id', 'title', 'description'
287
-
288
- Returns:
289
- List of dicts with 'id', 'score', 'reasoning', 'prob_positive', 'prob_neutral', 'prob_negative'
290
-
291
- Raises:
292
- Exception on API error or JSON parse failure
293
  """
294
  settings = get_settings()
295
-
296
  if not settings.openrouter_api_key:
297
  raise RuntimeError("OpenRouter API key not configured")
298
-
299
- # Build articles text for prompt
300
  articles_text = "\n".join([
301
- f"{i+1}. [ID:{a['id']}] {a['title']}" + (f" - {a['description'][:200]}" if a.get('description') else "")
302
  for i, a in enumerate(articles)
303
  ])
304
-
305
  user_prompt = f"""Score these {len(articles)} news articles for copper market sentiment.
306
 
307
  Articles:
308
  {articles_text}
309
 
310
- Return ONLY a valid JSON array with this exact structure (no markdown code blocks):
311
- [
312
- {{"id": <article_id>, "score": <float from -1.0 to 1.0>, "reasoning": "<brief explanation>"}},
313
- ...
314
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
 
316
- Rules:
317
- - score: -1.0 (very bearish) to +1.0 (very bullish), 0 = neutral
318
- - reasoning: 1 sentence max explaining the copper market impact
319
- - Include ALL {len(articles)} articles in your response"""
320
-
321
- async with httpx.AsyncClient(timeout=60.0) as client:
322
- response = await client.post(
323
- "https://openrouter.ai/api/v1/chat/completions",
324
- headers={
325
- "Authorization": f"Bearer {settings.openrouter_api_key}",
326
- "Content-Type": "application/json",
327
- "HTTP-Referer": "https://copper-mind.vercel.app",
328
- "X-Title": "CopperMind Sentiment Analysis",
329
- },
330
- json={
331
- "model": settings.llm_sentiment_model,
332
- "messages": [
333
- {"role": "system", "content": LLM_SENTIMENT_SYSTEM_PROMPT},
334
- {"role": "user", "content": user_prompt}
335
- ],
336
- "max_tokens": 2000,
337
- "temperature": 0.3, # Lower temperature for consistent scoring
338
- }
339
- )
340
-
341
- if response.status_code != 200:
342
- raise RuntimeError(f"OpenRouter API error: {response.status_code} - {response.text}")
343
-
344
- data = response.json()
345
- content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
346
-
347
- if not content:
348
- raise RuntimeError("Empty response from LLM")
349
-
350
- # Clean up response - remove markdown code blocks if present
351
- content = content.strip()
352
- if content.startswith("```"):
353
- # Remove ```json and ``` markers
354
- lines = content.split("\n")
355
- content = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
356
-
357
- # Parse JSON
358
- try:
359
- results = json.loads(content)
360
- except json.JSONDecodeError as e:
361
- logger.error(f"LLM JSON parse error: {e}\nContent: {content[:500]}")
362
- raise
363
-
364
- # Validate and enrich results
365
- enriched = []
366
- for item in results:
367
- score = float(item.get("score", 0))
368
- # Clamp score to [-1, 1]
369
- score = max(-1.0, min(1.0, score))
370
-
371
- # Derive probabilities from score
372
- # score = prob_positive - prob_negative
373
- # Assume prob_neutral is inverse of confidence
374
- confidence = abs(score)
375
- if score > 0:
376
- prob_positive = 0.33 + (confidence * 0.67)
377
- prob_negative = 0.33 - (confidence * 0.33)
378
- prob_neutral = 1.0 - prob_positive - prob_negative
379
- elif score < 0:
380
- prob_negative = 0.33 + (confidence * 0.67)
381
- prob_positive = 0.33 - (confidence * 0.33)
382
- prob_neutral = 1.0 - prob_positive - prob_negative
383
- else:
384
- prob_positive = 0.33
385
- prob_neutral = 0.34
386
- prob_negative = 0.33
387
-
388
- enriched.append({
389
- "id": item.get("id"),
390
- "score": score,
391
- "reasoning": item.get("reasoning", ""),
392
- "prob_positive": round(prob_positive, 4),
393
- "prob_neutral": round(prob_neutral, 4),
394
- "prob_negative": round(prob_negative, 4),
395
- })
396
-
397
- return enriched
398
 
399
 
400
  def score_batch_with_finbert(articles: list) -> list[dict]:
@@ -435,10 +485,10 @@ def score_unscored_articles(
435
  Score all articles that don't have sentiment scores yet.
436
 
437
  Strategy:
438
- - Primary: LLM (Gemini) with copper-specific context
439
  - Fallback: FinBERT per chunk if LLM fails
440
  - Chunk size: 20 articles for error isolation
441
- - Rate limiting: 2 second delay between chunks
442
 
443
  Returns:
444
  Number of articles scored
@@ -459,6 +509,9 @@ def score_unscored_articles(
459
 
460
  scored_count = 0
461
  total_chunks = (len(unscored) + chunk_size - 1) // chunk_size
 
 
 
462
 
463
  # Process in chunks
464
  for chunk_idx in range(0, len(unscored), chunk_size):
@@ -466,31 +519,52 @@ def score_unscored_articles(
466
  chunk_num = chunk_idx // chunk_size + 1
467
 
468
  logger.info(f"Processing chunk {chunk_num}/{total_chunks} ({len(chunk)} articles)")
469
-
470
- # Prepare articles for LLM
471
- articles_data = [
472
- {"id": a.id, "title": a.title, "description": a.description}
473
- for a in chunk
474
- ]
475
-
476
- results = None
477
- used_model = settings.llm_sentiment_model
478
-
479
- # Try LLM first
480
- if settings.openrouter_api_key:
 
 
 
 
 
 
 
 
 
 
 
481
  try:
482
- # Bridge async scoring into sync callers without nested-loop errors.
483
- results = run_async_from_sync(score_batch_with_llm, articles_data)
484
- logger.info(f"LLM scored chunk {chunk_num} successfully")
 
 
 
 
 
 
485
  except Exception as e:
486
  logger.warning(f"LLM scoring failed for chunk {chunk_num}, falling back to FinBERT: {e}")
487
- results = None
488
-
489
- # Fallback to FinBERT if LLM failed or not configured
490
- if results is None:
491
- logger.info(f"Using FinBERT fallback for chunk {chunk_num}")
492
- results = score_batch_with_finbert(chunk)
493
- used_model = "ProsusAI/finbert"
 
 
 
 
494
 
495
  # Create a lookup for results
496
  results_by_id = {r["id"]: r for r in results}
@@ -507,6 +581,7 @@ def score_unscored_articles(
507
  "prob_positive": 0.33,
508
  "prob_neutral": 0.34,
509
  "prob_negative": 0.33,
 
510
  }
511
 
512
  sentiment = NewsSentiment(
@@ -516,7 +591,7 @@ def score_unscored_articles(
516
  prob_negative=result["prob_negative"],
517
  score=result["score"],
518
  reasoning=result.get("reasoning"),
519
- model_name=result.get("model_name", used_model),
520
  scored_at=datetime.now(timezone.utc)
521
  )
522
 
@@ -526,11 +601,6 @@ def score_unscored_articles(
526
  # Commit after each chunk
527
  session.commit()
528
  logger.info(f"Committed chunk {chunk_num}: {len(chunk)} articles")
529
-
530
- # Rate limiting: 2 second delay between chunks (except last)
531
- if chunk_idx + chunk_size < len(unscored):
532
- logger.debug("Rate limit delay: 2 seconds")
533
- time.sleep(2)
534
 
535
  logger.info(f"Total articles scored: {scored_count}")
536
  return scored_count
 
2
  AI Engine: LLM sentiment scoring (with FinBERT fallback) + XGBoost training.
3
 
4
  Sentiment Analysis:
5
+ Primary: OpenRouter LLM with structured outputs
6
  Fallback: FinBERT for generic financial sentiment
7
 
8
  Usage:
 
15
  import json
16
  import logging
17
  import os
18
+ from functools import lru_cache
19
  from datetime import datetime, timedelta, timezone
20
  from pathlib import Path
21
  from typing import Any, Optional
22
 
 
 
 
 
 
23
  import numpy as np
24
  import pandas as pd
25
  from sqlalchemy import func
 
33
  from app.features import build_feature_matrix, get_feature_descriptions
34
  from app.lock import pipeline_lock
35
  from app.async_bridge import run_async_from_sync
36
+ from app.openrouter_client import OpenRouterError, create_chat_completion
37
 
38
  logging.basicConfig(
39
  level=logging.INFO,
 
119
  )
120
  _FINBERT_OUTPUT_LOGGED = True
121
 
122
+ @lru_cache(maxsize=1)
123
  def get_finbert_pipeline():
124
  """
125
  Load FinBERT model pipeline.
126
  Lazy loading to avoid import overhead when not needed.
127
  """
128
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
129
+ os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
130
+ os.environ.setdefault("TRANSFORMERS_NO_ADVISORY_WARNINGS", "1")
131
+
132
  from transformers import pipeline, AutoModelForSequenceClassification, AutoTokenizer
133
 
134
  model_name = "ProsusAI/finbert"
 
215
 
216
 
217
  # =============================================================================
218
+ # LLM Sentiment Scoring (Primary - OpenRouter)
219
  # =============================================================================
220
 
221
  # Copper-specific system prompt for LLM sentiment analysis
 
277
  - Use standard decimals (e.g., -0.4, 0.15, 1.0); no NaN, no scientific notation."""
278
 
279
 
280
+ LLM_SCORING_RESPONSE_FORMAT = {
281
+ "type": "json_schema",
282
+ "json_schema": {
283
+ "name": "news_sentiment_scores",
284
+ "strict": True,
285
+ "schema": {
286
+ "type": "array",
287
+ "items": {
288
+ "type": "object",
289
+ "properties": {
290
+ "id": {"type": "integer"},
291
+ "score": {"type": "number", "minimum": -1, "maximum": 1},
292
+ "reasoning": {"type": "string"},
293
+ },
294
+ "required": ["id", "score"],
295
+ "additionalProperties": False,
296
+ },
297
+ },
298
+ },
299
+ }
300
+
301
+ LLM_SCORING_PROVIDER_OPTIONS = {"require_parameters": True}
302
+
303
+
304
+ def _derive_probs_from_score(score: float) -> tuple[float, float, float]:
305
+ """Derive pseudo-probabilities from signed score for downstream compatibility."""
306
+ confidence = abs(score)
307
+ if score > 0:
308
+ prob_positive = 0.33 + (confidence * 0.67)
309
+ prob_negative = 0.33 - (confidence * 0.33)
310
+ prob_neutral = 1.0 - prob_positive - prob_negative
311
+ elif score < 0:
312
+ prob_negative = 0.33 + (confidence * 0.67)
313
+ prob_positive = 0.33 - (confidence * 0.33)
314
+ prob_neutral = 1.0 - prob_positive - prob_negative
315
+ else:
316
+ prob_positive = 0.33
317
+ prob_neutral = 0.34
318
+ prob_negative = 0.33
319
+
320
+ return round(prob_positive, 4), round(prob_neutral, 4), round(prob_negative, 4)
321
+
322
+
323
+ def _extract_chat_message_content(data: dict[str, Any]) -> str:
324
+ """Extract text content from OpenRouter chat completion response."""
325
+ message = data.get("choices", [{}])[0].get("message", {})
326
+ content = message.get("content", "")
327
+
328
+ if isinstance(content, str):
329
+ return content.strip()
330
+
331
+ if isinstance(content, list):
332
+ text_parts: list[str] = []
333
+ for item in content:
334
+ if isinstance(item, dict) and item.get("type") == "text":
335
+ text = item.get("text")
336
+ if isinstance(text, str):
337
+ text_parts.append(text)
338
+ return "\n".join(text_parts).strip()
339
+
340
+ return ""
341
+
342
+
343
+ def _validate_and_enrich_llm_results(
344
+ *,
345
+ raw_results: Any,
346
+ expected_ids: list[int],
347
+ model_name: str,
348
+ ) -> list[dict]:
349
+ """Validate LLM result shape and enrich with derived probability fields."""
350
+ if not isinstance(raw_results, list):
351
+ raise ValueError(f"Structured result must be a list, got {type(raw_results).__name__}")
352
+
353
+ results_by_id: dict[int, dict] = {}
354
+ for item in raw_results:
355
+ if not isinstance(item, dict):
356
+ raise ValueError(f"Structured result item must be object, got {type(item).__name__}")
357
+ if "id" not in item or "score" not in item:
358
+ raise ValueError("Structured result missing required fields: id and score")
359
+
360
+ article_id = int(item["id"])
361
+ if article_id in results_by_id:
362
+ raise ValueError(f"Duplicate article id in structured output: {article_id}")
363
+ score = max(-1.0, min(1.0, float(item["score"])))
364
+ reasoning_raw = item.get("reasoning", "")
365
+ reasoning = reasoning_raw if isinstance(reasoning_raw, str) else str(reasoning_raw)
366
+
367
+ prob_positive, prob_neutral, prob_negative = _derive_probs_from_score(score)
368
+ results_by_id[article_id] = {
369
+ "id": article_id,
370
+ "score": score,
371
+ "reasoning": reasoning,
372
+ "prob_positive": prob_positive,
373
+ "prob_neutral": prob_neutral,
374
+ "prob_negative": prob_negative,
375
+ "model_name": model_name,
376
+ }
377
+
378
+ expected = set(expected_ids)
379
+ got = set(results_by_id.keys())
380
+ missing = sorted(expected - got)
381
+ extra = sorted(got - expected)
382
+ if missing or extra:
383
+ raise ValueError(f"Structured result ID mismatch. missing={missing} extra={extra}")
384
+
385
+ return [results_by_id[article_id] for article_id in expected_ids]
386
+
387
+
388
  async def score_batch_with_llm(
389
  articles: list[dict],
390
  ) -> list[dict]:
391
  """
392
+ Score a batch of articles using OpenRouter with strict JSON schema response.
 
 
 
 
 
 
 
 
 
393
  """
394
  settings = get_settings()
395
+
396
  if not settings.openrouter_api_key:
397
  raise RuntimeError("OpenRouter API key not configured")
398
+
 
399
  articles_text = "\n".join([
400
+ f"{i+1}. [ID:{a['id']}] {a['title']}" + (f" - {a['description'][:200]}" if a.get("description") else "")
401
  for i, a in enumerate(articles)
402
  ])
403
+
404
  user_prompt = f"""Score these {len(articles)} news articles for copper market sentiment.
405
 
406
  Articles:
407
  {articles_text}
408
 
409
+ Output must follow the provided JSON schema."""
410
+
411
+ model_name = settings.resolved_scoring_model
412
+
413
+ data = await create_chat_completion(
414
+ api_key=settings.openrouter_api_key,
415
+ model=model_name,
416
+ messages=[
417
+ {"role": "system", "content": LLM_SENTIMENT_SYSTEM_PROMPT},
418
+ {"role": "user", "content": user_prompt},
419
+ ],
420
+ max_tokens=2000,
421
+ temperature=0.3,
422
+ timeout_seconds=60.0,
423
+ max_retries=settings.openrouter_max_retries,
424
+ rpm=settings.openrouter_rpm,
425
+ response_format=LLM_SCORING_RESPONSE_FORMAT,
426
+ provider=LLM_SCORING_PROVIDER_OPTIONS,
427
+ fallback_models=settings.openrouter_fallback_models_list,
428
+ referer="https://copper-mind.vercel.app",
429
+ title="CopperMind Sentiment Analysis",
430
+ )
431
 
432
+ content = _extract_chat_message_content(data)
433
+ if not content:
434
+ raise OpenRouterError("Empty response content from LLM scoring")
435
+
436
+ try:
437
+ raw_results = json.loads(content)
438
+ except json.JSONDecodeError as exc:
439
+ logger.error("LLM JSON parse error after structured output: %s", exc)
440
+ raise
441
+
442
+ expected_ids = [int(article["id"]) for article in articles]
443
+ return _validate_and_enrich_llm_results(
444
+ raw_results=raw_results,
445
+ expected_ids=expected_ids,
446
+ model_name=model_name,
447
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
 
449
 
450
  def score_batch_with_finbert(articles: list) -> list[dict]:
 
485
  Score all articles that don't have sentiment scores yet.
486
 
487
  Strategy:
488
+ - Primary: OpenRouter LLM with strict JSON schema output
489
  - Fallback: FinBERT per chunk if LLM fails
490
  - Chunk size: 20 articles for error isolation
491
+ - Run budget: cap LLM-scored articles per run, overflow uses FinBERT
492
 
493
  Returns:
494
  Number of articles scored
 
509
 
510
  scored_count = 0
511
  total_chunks = (len(unscored) + chunk_size - 1) // chunk_size
512
+ llm_budget_remaining = max(0, settings.max_llm_articles_per_run)
513
+ budget_exhausted_logged = False
514
+ logger.info("LLM scoring budget for this run: %s articles", llm_budget_remaining)
515
 
516
  # Process in chunks
517
  for chunk_idx in range(0, len(unscored), chunk_size):
 
519
  chunk_num = chunk_idx // chunk_size + 1
520
 
521
  logger.info(f"Processing chunk {chunk_num}/{total_chunks} ({len(chunk)} articles)")
522
+
523
+ llm_candidates: list[Any] = []
524
+ finbert_candidates: list[Any] = []
525
+ results: list[dict] = []
526
+
527
+ if settings.openrouter_api_key and llm_budget_remaining > 0:
528
+ llm_take = min(len(chunk), llm_budget_remaining)
529
+ llm_candidates = chunk[:llm_take]
530
+ finbert_candidates = chunk[llm_take:]
531
+ else:
532
+ finbert_candidates = chunk
533
+ if settings.openrouter_api_key and llm_budget_remaining <= 0 and not budget_exhausted_logged:
534
+ logger.info(
535
+ "LLM budget exhausted (%s articles). Remaining chunks will use FinBERT fallback.",
536
+ settings.max_llm_articles_per_run,
537
+ )
538
+ budget_exhausted_logged = True
539
+
540
+ if llm_candidates:
541
+ articles_data = [
542
+ {"id": a.id, "title": a.title, "description": a.description}
543
+ for a in llm_candidates
544
+ ]
545
  try:
546
+ llm_results = run_async_from_sync(score_batch_with_llm, articles_data)
547
+ results.extend(llm_results)
548
+ llm_budget_remaining -= len(llm_candidates)
549
+ logger.info(
550
+ "LLM scored %s article(s) in chunk %s. Budget remaining: %s",
551
+ len(llm_candidates),
552
+ chunk_num,
553
+ llm_budget_remaining,
554
+ )
555
  except Exception as e:
556
  logger.warning(f"LLM scoring failed for chunk {chunk_num}, falling back to FinBERT: {e}")
557
+ finbert_candidates = chunk
558
+ results = []
559
+
560
+ if finbert_candidates:
561
+ logger.info(
562
+ "Using FinBERT fallback for %s article(s) in chunk %s",
563
+ len(finbert_candidates),
564
+ chunk_num,
565
+ )
566
+ finbert_results = score_batch_with_finbert(finbert_candidates)
567
+ results.extend(finbert_results)
568
 
569
  # Create a lookup for results
570
  results_by_id = {r["id"]: r for r in results}
 
581
  "prob_positive": 0.33,
582
  "prob_neutral": 0.34,
583
  "prob_negative": 0.33,
584
+ "model_name": "ProsusAI/finbert",
585
  }
586
 
587
  sentiment = NewsSentiment(
 
591
  prob_negative=result["prob_negative"],
592
  score=result["score"],
593
  reasoning=result.get("reasoning"),
594
+ model_name=result.get("model_name", settings.resolved_scoring_model),
595
  scored_at=datetime.now(timezone.utc)
596
  )
597
 
 
601
  # Commit after each chunk
602
  session.commit()
603
  logger.info(f"Committed chunk {chunk_num}: {len(chunk)} articles")
 
 
 
 
 
604
 
605
  logger.info(f"Total articles scored: {scored_count}")
606
  return scored_count
app/commentary.py CHANGED
@@ -4,19 +4,62 @@ Generates human-readable market analysis from FinBERT + XGBoost results.
4
  """
5
 
6
  import logging
7
-
8
- # Suppress httpx request logging to prevent API keys in URLs from appearing in logs
9
- logging.getLogger("httpx").setLevel(logging.WARNING)
10
-
11
- import httpx
12
  from typing import Optional
13
  from datetime import datetime
14
 
15
  from .settings import get_settings
 
16
 
17
  logger = logging.getLogger(__name__)
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  async def determine_ai_stance(commentary: str) -> str:
21
  """
22
  Have the AI analyze its own commentary to determine market stance.
@@ -43,34 +86,26 @@ Commentary:
43
  Your response (one word only):"""
44
 
45
  try:
46
- async with httpx.AsyncClient(timeout=30.0) as client:
47
- response = await client.post(
48
- "https://openrouter.ai/api/v1/chat/completions",
49
- headers={
50
- "Authorization": f"Bearer {settings.openrouter_api_key}",
51
- "Content-Type": "application/json",
52
- },
53
- json={
54
- "model": settings.openrouter_model,
55
- "messages": [{"role": "user", "content": prompt}],
56
- "max_tokens": 10,
57
- "temperature": 0.1,
58
- }
59
- )
60
-
61
- if response.status_code == 200:
62
- data = response.json()
63
- stance = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip().upper()
64
-
65
- # Validate response
66
- if stance in ["BULLISH", "NEUTRAL", "BEARISH"]:
67
- logger.info(f"AI stance determined: {stance}")
68
- return stance
69
- else:
70
- logger.warning(f"Invalid AI stance response: '{stance}', using keyword fallback")
71
- else:
72
- logger.warning(f"AI stance API error: {response.status_code}, using keyword fallback")
73
-
74
  except Exception as e:
75
  logger.warning(f"AI stance detection failed: {e}, using keyword fallback")
76
 
@@ -134,9 +169,19 @@ async def generate_commentary(
134
  """
135
  settings = get_settings()
136
 
 
 
 
 
 
 
 
 
 
 
137
  if not settings.openrouter_api_key:
138
- logger.warning("OpenRouter API key not configured, skipping commentary")
139
- return None
140
 
141
  # Build the prompt
142
  influencers_text = "\n".join([
@@ -184,48 +229,41 @@ Output requirements:
184
  - End with this exact line on its own: This is NOT financial advice."""
185
 
186
  try:
187
- async with httpx.AsyncClient(timeout=30.0) as client:
188
- response = await client.post(
189
- "https://openrouter.ai/api/v1/chat/completions",
190
- headers={
191
- "Authorization": f"Bearer {settings.openrouter_api_key}",
192
- "Content-Type": "application/json",
193
- "HTTP-Referer": "https://copper-mind.vercel.app",
194
- "X-Title": "CopperMind AI Analysis",
195
  },
196
- json={
197
- "model": settings.openrouter_model,
198
- "messages": [
199
- {
200
- "role": "system",
201
- "content": system_prompt
202
- },
203
- {
204
- "role": "user",
205
- "content": prompt
206
- }
207
- ],
208
- "max_tokens": 700,
209
- "temperature": 0.6,
210
- }
211
- )
212
-
213
- if response.status_code == 200:
214
- data = response.json()
215
- commentary = data.get("choices", [{}])[0].get("message", {}).get("content", "")
216
- if commentary:
217
- logger.info(f"AI commentary generated successfully ({len(commentary)} chars)")
218
- return commentary.strip()
219
- else:
220
- logger.warning("Empty response from OpenRouter")
221
- return None
222
- else:
223
- logger.error(f"OpenRouter API error: {response.status_code} - {response.text}")
224
- return None
225
-
226
  except Exception as e:
227
  logger.error(f"Failed to generate AI commentary: {e}")
228
- return None
229
 
230
 
231
  def save_commentary_to_db(
@@ -258,7 +296,7 @@ def save_commentary_to_db(
258
  existing.sentiment_label = sentiment_label
259
  existing.ai_stance = ai_stance
260
  existing.generated_at = datetime.utcnow()
261
- existing.model_name = settings.openrouter_model
262
  logger.info(f"Updated AI commentary for {symbol} (stance: {ai_stance})")
263
  else:
264
  # Create new
@@ -270,7 +308,7 @@ def save_commentary_to_db(
270
  predicted_return=predicted_return,
271
  sentiment_label=sentiment_label,
272
  ai_stance=ai_stance,
273
- model_name=settings.openrouter_model,
274
  )
275
  session.add(new_commentary)
276
  logger.info(f"Created new AI commentary for {symbol} (stance: {ai_stance})")
 
4
  """
5
 
6
  import logging
 
 
 
 
 
7
  from typing import Optional
8
  from datetime import datetime
9
 
10
  from .settings import get_settings
11
+ from .openrouter_client import OpenRouterError, create_chat_completion
12
 
13
  logger = logging.getLogger(__name__)
14
 
15
 
16
+ def _extract_chat_message_content(data: dict) -> str:
17
+ """Extract text content from OpenRouter chat completion response."""
18
+ message = data.get("choices", [{}])[0].get("message", {})
19
+ content = message.get("content", "")
20
+ if isinstance(content, str):
21
+ return content.strip()
22
+ if isinstance(content, list):
23
+ text_parts: list[str] = []
24
+ for item in content:
25
+ if isinstance(item, dict) and item.get("type") == "text":
26
+ text = item.get("text")
27
+ if isinstance(text, str):
28
+ text_parts.append(text)
29
+ return "\n".join(text_parts).strip()
30
+ return ""
31
+
32
+
33
+ def _build_commentary_template_fallback(
34
+ current_price: float,
35
+ predicted_price: float,
36
+ predicted_return: float,
37
+ sentiment_index: float,
38
+ sentiment_label: str,
39
+ top_influencers: list[dict],
40
+ news_count: int,
41
+ ) -> str:
42
+ """Deterministic fallback commentary used when LLM is unavailable."""
43
+ direction = "upside" if predicted_return >= 0 else "downside"
44
+ top_driver_names = [inf.get("feature", "unknown_driver") for inf in top_influencers[:3]]
45
+ while len(top_driver_names) < 3:
46
+ top_driver_names.append("unknown_driver")
47
+
48
+ return "\n".join([
49
+ "Risks:",
50
+ f"1. Model indicates {direction} uncertainty around the next-day move ({predicted_return * 100:.2f}%).",
51
+ f"2. Sentiment regime is {sentiment_label} with score {sentiment_index:.3f}, which can reverse quickly.",
52
+ f"3. News sample size ({news_count}) may be insufficient for stable short-horizon inference.",
53
+ "Opportunities:",
54
+ f"1. Predicted price path implies a move from ${current_price:.4f} to ${predicted_price:.4f}.",
55
+ f"2. Feature signal concentration around `{top_driver_names[0]}` can support tactical monitoring.",
56
+ f"3. Secondary drivers `{top_driver_names[1]}` and `{top_driver_names[2]}` provide confirmation checkpoints.",
57
+ f"Summary: Current model inputs suggest a cautious {direction} bias with elevated uncertainty.",
58
+ "Bias warning: This view is model-driven and sensitive to news mix, data latency, and feature drift.",
59
+ "This is NOT financial advice.",
60
+ ])
61
+
62
+
63
  async def determine_ai_stance(commentary: str) -> str:
64
  """
65
  Have the AI analyze its own commentary to determine market stance.
 
86
  Your response (one word only):"""
87
 
88
  try:
89
+ data = await create_chat_completion(
90
+ api_key=settings.openrouter_api_key,
91
+ model=settings.resolved_commentary_model,
92
+ messages=[{"role": "user", "content": prompt}],
93
+ max_tokens=10,
94
+ temperature=0.1,
95
+ timeout_seconds=30.0,
96
+ max_retries=settings.openrouter_max_retries,
97
+ rpm=settings.openrouter_rpm,
98
+ fallback_models=settings.openrouter_fallback_models_list,
99
+ )
100
+ stance = _extract_chat_message_content(data).upper()
101
+
102
+ # Validate response
103
+ if stance in ["BULLISH", "NEUTRAL", "BEARISH"]:
104
+ logger.info(f"AI stance determined: {stance}")
105
+ return stance
106
+ logger.warning(f"Invalid AI stance response: '{stance}', using keyword fallback")
107
+ except OpenRouterError as e:
108
+ logger.warning(f"AI stance detection failed via OpenRouter: {e}, using keyword fallback")
 
 
 
 
 
 
 
 
109
  except Exception as e:
110
  logger.warning(f"AI stance detection failed: {e}, using keyword fallback")
111
 
 
169
  """
170
  settings = get_settings()
171
 
172
+ fallback_commentary = _build_commentary_template_fallback(
173
+ current_price=current_price,
174
+ predicted_price=predicted_price,
175
+ predicted_return=predicted_return,
176
+ sentiment_index=sentiment_index,
177
+ sentiment_label=sentiment_label,
178
+ top_influencers=top_influencers,
179
+ news_count=news_count,
180
+ )
181
+
182
  if not settings.openrouter_api_key:
183
+ logger.warning("OpenRouter API key not configured, using template commentary fallback")
184
+ return fallback_commentary
185
 
186
  # Build the prompt
187
  influencers_text = "\n".join([
 
229
  - End with this exact line on its own: This is NOT financial advice."""
230
 
231
  try:
232
+ data = await create_chat_completion(
233
+ api_key=settings.openrouter_api_key,
234
+ model=settings.resolved_commentary_model,
235
+ messages=[
236
+ {
237
+ "role": "system",
238
+ "content": system_prompt,
 
239
  },
240
+ {
241
+ "role": "user",
242
+ "content": prompt,
243
+ },
244
+ ],
245
+ max_tokens=700,
246
+ temperature=0.6,
247
+ timeout_seconds=30.0,
248
+ max_retries=settings.openrouter_max_retries,
249
+ rpm=settings.openrouter_rpm,
250
+ fallback_models=settings.openrouter_fallback_models_list,
251
+ referer="https://copper-mind.vercel.app",
252
+ title="CopperMind AI Analysis",
253
+ )
254
+ commentary = _extract_chat_message_content(data)
255
+ if commentary:
256
+ logger.info(f"AI commentary generated successfully ({len(commentary)} chars)")
257
+ return commentary.strip()
258
+
259
+ logger.warning("Empty response from OpenRouter, using template commentary fallback")
260
+ return fallback_commentary
261
+ except OpenRouterError as e:
262
+ logger.warning("OpenRouter commentary failed: %s. Using template fallback.", e)
263
+ return fallback_commentary
 
 
 
 
 
 
264
  except Exception as e:
265
  logger.error(f"Failed to generate AI commentary: {e}")
266
+ return fallback_commentary
267
 
268
 
269
  def save_commentary_to_db(
 
296
  existing.sentiment_label = sentiment_label
297
  existing.ai_stance = ai_stance
298
  existing.generated_at = datetime.utcnow()
299
+ existing.model_name = settings.resolved_commentary_model
300
  logger.info(f"Updated AI commentary for {symbol} (stance: {ai_stance})")
301
  else:
302
  # Create new
 
308
  predicted_return=predicted_return,
309
  sentiment_label=sentiment_label,
310
  ai_stance=ai_stance,
311
+ model_name=settings.resolved_commentary_model,
312
  )
313
  session.add(new_commentary)
314
  logger.info(f"Created new AI commentary for {symbol} (stance: {ai_stance})")
app/models.py CHANGED
@@ -105,7 +105,7 @@ class PriceBar(Base):
105
  class NewsSentiment(Base):
106
  """
107
  Sentiment scores for each news article.
108
- Primary: LLM (Gemini) with copper-specific context
109
  Fallback: FinBERT for generic financial sentiment
110
  One-to-one relationship with NewsArticle.
111
  """
 
105
  class NewsSentiment(Base):
106
  """
107
  Sentiment scores for each news article.
108
+ Primary: LLM (OpenRouter structured outputs) with copper-specific context
109
  Fallback: FinBERT for generic financial sentiment
110
  One-to-one relationship with NewsArticle.
111
  """
app/openrouter_client.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared OpenRouter client with retry, throttling, and model fallback support.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import logging
9
+ import random
10
+ import threading
11
+ import time
12
+ from typing import Any, Optional
13
+
14
+ import httpx
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _RATE_LOCK = threading.Lock()
19
+ _NEXT_ALLOWED_TS = 0.0
20
+
21
+
22
+ class OpenRouterError(RuntimeError):
23
+ """Base error raised for OpenRouter client failures."""
24
+
25
+ def __init__(self, message: str, status_code: Optional[int] = None):
26
+ super().__init__(message)
27
+ self.status_code = status_code
28
+
29
+
30
+ class OpenRouterRateLimitError(OpenRouterError):
31
+ """Raised when OpenRouter rate limiting persists after retries."""
32
+
33
+
34
+ def _parse_retry_after_seconds(response: httpx.Response) -> Optional[float]:
35
+ """Parse Retry-After header in seconds if provided."""
36
+ value = response.headers.get("Retry-After")
37
+ if not value:
38
+ return None
39
+ try:
40
+ seconds = float(value)
41
+ return max(seconds, 0.0)
42
+ except ValueError:
43
+ return None
44
+
45
+
46
+ def _build_model_payload(primary_model: str, fallback_models: Optional[list[str]]) -> dict[str, Any]:
47
+ """
48
+ Build model payload for OpenRouter.
49
+ Uses `models` only when fallback models are provided.
50
+ """
51
+ if not fallback_models:
52
+ return {"model": primary_model}
53
+
54
+ ordered: list[str] = []
55
+ for model in [primary_model, *fallback_models]:
56
+ if model and model not in ordered:
57
+ ordered.append(model)
58
+
59
+ if len(ordered) == 1:
60
+ return {"model": ordered[0]}
61
+
62
+ return {"models": ordered}
63
+
64
+
65
+ async def _throttle_request(rpm: int) -> None:
66
+ """
67
+ Global soft-throttle shared across all OpenRouter requests in this process.
68
+ """
69
+ if rpm <= 0:
70
+ return
71
+
72
+ min_interval = 60.0 / float(rpm)
73
+ now = time.monotonic()
74
+ wait_seconds = 0.0
75
+
76
+ global _NEXT_ALLOWED_TS
77
+ with _RATE_LOCK:
78
+ if now < _NEXT_ALLOWED_TS:
79
+ wait_seconds = _NEXT_ALLOWED_TS - now
80
+ _NEXT_ALLOWED_TS += min_interval
81
+ else:
82
+ _NEXT_ALLOWED_TS = now + min_interval
83
+
84
+ if wait_seconds > 0:
85
+ logger.debug("OpenRouter throttle wait: %.3fs", wait_seconds)
86
+ await asyncio.sleep(wait_seconds)
87
+
88
+
89
+ async def create_chat_completion(
90
+ *,
91
+ api_key: str,
92
+ model: str,
93
+ messages: list[dict[str, Any]],
94
+ max_tokens: Optional[int] = None,
95
+ temperature: Optional[float] = None,
96
+ timeout_seconds: float = 60.0,
97
+ max_retries: int = 3,
98
+ rpm: int = 18,
99
+ response_format: Optional[dict[str, Any]] = None,
100
+ provider: Optional[dict[str, Any]] = None,
101
+ fallback_models: Optional[list[str]] = None,
102
+ referer: Optional[str] = None,
103
+ title: Optional[str] = None,
104
+ extra_payload: Optional[dict[str, Any]] = None,
105
+ ) -> dict[str, Any]:
106
+ """
107
+ Call OpenRouter chat completions with retry/backoff and soft throttling.
108
+
109
+ Retry policy:
110
+ - Retry on 429 and 5xx
111
+ - Retry on transient network errors
112
+ - Delay: Retry-After (if present) else 2^attempt + jitter(0..0.5)
113
+ """
114
+ if not api_key:
115
+ raise OpenRouterError("OpenRouter API key not configured")
116
+
117
+ payload: dict[str, Any] = {
118
+ **_build_model_payload(model, fallback_models),
119
+ "messages": messages,
120
+ }
121
+
122
+ if max_tokens is not None:
123
+ payload["max_tokens"] = max_tokens
124
+ if temperature is not None:
125
+ payload["temperature"] = temperature
126
+ if response_format is not None:
127
+ payload["response_format"] = response_format
128
+ if provider is not None:
129
+ payload["provider"] = provider
130
+ if extra_payload:
131
+ payload.update(extra_payload)
132
+
133
+ headers = {
134
+ "Authorization": f"Bearer {api_key}",
135
+ "Content-Type": "application/json",
136
+ }
137
+ if referer:
138
+ headers["HTTP-Referer"] = referer
139
+ if title:
140
+ headers["X-Title"] = title
141
+
142
+ async with httpx.AsyncClient(timeout=timeout_seconds) as client:
143
+ for attempt in range(max_retries + 1):
144
+ await _throttle_request(rpm)
145
+ try:
146
+ response = await client.post(
147
+ "https://openrouter.ai/api/v1/chat/completions",
148
+ headers=headers,
149
+ json=payload,
150
+ )
151
+ except httpx.RequestError as exc:
152
+ if attempt >= max_retries:
153
+ raise OpenRouterError(
154
+ f"OpenRouter request failed after retries: {exc}"
155
+ ) from exc
156
+
157
+ retry_num = attempt + 1
158
+ delay = float(2 ** retry_num) + random.uniform(0.0, 0.5)
159
+ logger.warning(
160
+ "OpenRouter network error (attempt %s/%s). Retrying in %.2fs: %s",
161
+ retry_num,
162
+ max_retries,
163
+ delay,
164
+ exc,
165
+ )
166
+ await asyncio.sleep(delay)
167
+ continue
168
+
169
+ if response.status_code == 200:
170
+ try:
171
+ return response.json()
172
+ except ValueError as exc:
173
+ raise OpenRouterError("OpenRouter returned non-JSON response body") from exc
174
+
175
+ retryable = response.status_code == 429 or 500 <= response.status_code < 600
176
+ if retryable and attempt < max_retries:
177
+ retry_num = attempt + 1
178
+ retry_after = _parse_retry_after_seconds(response)
179
+ delay = retry_after if retry_after is not None else float(2 ** retry_num) + random.uniform(0.0, 0.5)
180
+ logger.warning(
181
+ "OpenRouter retryable error status=%s (attempt %s/%s). Retrying in %.2fs",
182
+ response.status_code,
183
+ retry_num,
184
+ max_retries,
185
+ delay,
186
+ )
187
+ await asyncio.sleep(delay)
188
+ continue
189
+
190
+ body_preview = response.text[:500]
191
+ if response.status_code == 429:
192
+ raise OpenRouterRateLimitError(
193
+ f"OpenRouter rate limit exceeded after retries: {body_preview}",
194
+ status_code=response.status_code,
195
+ )
196
+ raise OpenRouterError(
197
+ f"OpenRouter API error: {response.status_code} - {body_preview}",
198
+ status_code=response.status_code,
199
+ )
200
+
201
+ raise OpenRouterError("OpenRouter request unexpectedly terminated")
app/settings.py CHANGED
@@ -68,12 +68,21 @@ class Settings(BaseSettings):
68
 
69
  # OpenRouter AI Commentary
70
  openrouter_api_key: Optional[str] = None
 
71
  openrouter_model: str = "openai/gpt-oss-120b:free"
 
 
 
 
 
 
 
72
 
73
  # Twelve Data (Live Price)
74
  twelvedata_api_key: Optional[str] = None
75
 
76
  # LLM Sentiment Analysis
 
77
  llm_sentiment_model: str = "openai/gpt-oss-120b:free"
78
 
79
  # Pipeline trigger authentication
@@ -158,6 +167,48 @@ class Settings(BaseSettings):
158
  symbols = self.symbols_list
159
  return symbols[0] if symbols else "HG=F"
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
  @lru_cache
163
  def get_settings() -> Settings:
 
68
 
69
  # OpenRouter AI Commentary
70
  openrouter_api_key: Optional[str] = None
71
+ # Deprecated - kept for backward compatibility
72
  openrouter_model: str = "openai/gpt-oss-120b:free"
73
+ # New primary config
74
+ openrouter_model_scoring: str = "stepfun/step-3.5-flash:free"
75
+ openrouter_model_commentary: str = "stepfun/step-3.5-flash:free"
76
+ openrouter_rpm: int = 18
77
+ openrouter_max_retries: int = 3
78
+ max_llm_articles_per_run: int = 200
79
+ openrouter_fallback_models: Optional[str] = None
80
 
81
  # Twelve Data (Live Price)
82
  twelvedata_api_key: Optional[str] = None
83
 
84
  # LLM Sentiment Analysis
85
+ # Deprecated - kept for backward compatibility
86
  llm_sentiment_model: str = "openai/gpt-oss-120b:free"
87
 
88
  # Pipeline trigger authentication
 
167
  symbols = self.symbols_list
168
  return symbols[0] if symbols else "HG=F"
169
 
170
+ @staticmethod
171
+ def _first_non_empty(*values: Optional[str]) -> Optional[str]:
172
+ """Return first non-empty string value."""
173
+ for value in values:
174
+ if value and value.strip():
175
+ return value.strip()
176
+ return None
177
+
178
+ @property
179
+ def resolved_scoring_model(self) -> str:
180
+ """Preferred scoring model with backward-compatible fallback chain."""
181
+ return (
182
+ self._first_non_empty(
183
+ self.openrouter_model_scoring,
184
+ self.llm_sentiment_model,
185
+ self.openrouter_model,
186
+ )
187
+ or "stepfun/step-3.5-flash:free"
188
+ )
189
+
190
+ @property
191
+ def resolved_commentary_model(self) -> str:
192
+ """Preferred commentary model with backward-compatible fallback chain."""
193
+ return (
194
+ self._first_non_empty(
195
+ self.openrouter_model_commentary,
196
+ self.openrouter_model,
197
+ self.llm_sentiment_model,
198
+ )
199
+ or "stepfun/step-3.5-flash:free"
200
+ )
201
+
202
+ @property
203
+ def openrouter_fallback_models_list(self) -> list[str]:
204
+ """
205
+ Parse comma-separated fallback models.
206
+ Empty/whitespace items are ignored.
207
+ """
208
+ if not self.openrouter_fallback_models:
209
+ return []
210
+ return [m.strip() for m in self.openrouter_fallback_models.split(",") if m.strip()]
211
+
212
 
213
  @lru_cache
214
  def get_settings() -> Settings: