implemented client side filters, mobile first styling and modals for articles
This commit is contained in:
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
DB_PATH = Path(os.getenv("DB_NAME", "owlynews.sqlite3"))
|
||||
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
||||
MIN_CRON_HOURS = float(os.getenv("MIN_CRON_HOURS", 0.5))
|
||||
MIN_CRON_HOURS = float(os.getenv("MIN_CRON_HOURS", 0.1))
|
||||
DEFAULT_CRON_HOURS = float(os.getenv("CRON_HOURS", MIN_CRON_HOURS))
|
||||
CRON_HOURS = max(MIN_CRON_HOURS, DEFAULT_CRON_HOURS)
|
||||
SYNC_COOLDOWN_MINUTES = int(os.getenv("SYNC_COOLDOWN_MINUTES", 30))
|
||||
@@ -21,7 +21,7 @@ frontend_path = os.path.join(
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING,
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@@ -175,31 +175,53 @@ class DatabaseManager:
|
||||
A database cursor for executing SQL statements
|
||||
"""
|
||||
conn = None
|
||||
cursor = None
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
if readonly:
|
||||
conn.execute("BEGIN DEFERRED")
|
||||
else:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
|
||||
cursor = conn.cursor()
|
||||
yield cursor
|
||||
|
||||
if not readonly:
|
||||
conn.commit()
|
||||
else:
|
||||
# For readonly transactions, we still need to end the transaction
|
||||
conn.commit()
|
||||
|
||||
except sqlite3.OperationalError as e:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
try:
|
||||
conn.rollback()
|
||||
except:
|
||||
pass
|
||||
if "database is locked" in str(e).lower():
|
||||
logger.warning(
|
||||
f"⚠️ Database temporarily locked, operation may need retry: {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
try:
|
||||
conn.rollback()
|
||||
except:
|
||||
pass
|
||||
raise e
|
||||
finally:
|
||||
if cursor:
|
||||
try:
|
||||
cursor.close()
|
||||
except:
|
||||
pass
|
||||
if conn:
|
||||
conn.close()
|
||||
try:
|
||||
conn.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_cursor_with_retry(self,
|
||||
|
@@ -22,6 +22,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from fastapi import Depends, FastAPI, HTTPException, Response, status
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from backend.app.config import (
|
||||
CRON_HOURS,
|
||||
@@ -65,51 +66,117 @@ scheduler.start()
|
||||
@app.get("/news", response_model=List[Dict[str, Any]])
|
||||
async def get_news(
|
||||
country: str = "DE",
|
||||
from_: str = "2025-07-01",
|
||||
to_: str = datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
||||
from_: str = None,
|
||||
to_: str = None,
|
||||
timezone_name: str = "UTC",
|
||||
all_countries: bool = False,
|
||||
all_dates: bool = False,
|
||||
db: sqlite3.Cursor = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get news articles filtered by country and date range.
|
||||
Now optimized for concurrent access while scheduler is running.
|
||||
Now handles client timezone properly and supports multiple countries and all news.
|
||||
|
||||
Args:
|
||||
country: Country code to filter by (default: "DE")
|
||||
from_: Start date in ISO format (default: "2025-07-01")
|
||||
to: End date in ISO format (default: current date)
|
||||
country: Country code(s) to filter by (default: "DE").
|
||||
For multiple countries, use comma-separated values like "DE,US,GB"
|
||||
from_: Start date in ISO format (optional)
|
||||
to_: End date in ISO format (optional)
|
||||
timezone_name: Client timezone for date interpretation (default: "UTC")
|
||||
all_countries: If True, returns news from all countries (overrides country param)
|
||||
all_dates: If True, returns news from all dates (overrides date params)
|
||||
db: Database cursor dependency
|
||||
|
||||
Returns:
|
||||
List of news articles matching the criteria
|
||||
"""
|
||||
try:
|
||||
datetime.fromisoformat(from_)
|
||||
datetime.fromisoformat(to_)
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from_ts = int(datetime.fromisoformat(from_).timestamp())
|
||||
to_ts = int(datetime.fromisoformat(to_).timestamp())
|
||||
# Handle timezone
|
||||
try:
|
||||
client_tz = ZoneInfo(timezone_name)
|
||||
except Exception:
|
||||
logger.warning(f"Invalid timezone '{timezone_name}', using UTC")
|
||||
client_tz = timezone.utc
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
SELECT id, title, description, url, published, country, created_at
|
||||
FROM news
|
||||
WHERE country = ?
|
||||
AND published BETWEEN ? AND ?
|
||||
ORDER BY published DESC LIMIT 1000
|
||||
""",
|
||||
(country, from_ts, to_ts)
|
||||
)
|
||||
where_conditions = []
|
||||
params = []
|
||||
|
||||
return [dict(row) for row in db.fetchall()]
|
||||
if not all_countries:
|
||||
countries = [c.strip().upper() for c in country.split(',') if c.strip()]
|
||||
if len(countries) == 1:
|
||||
where_conditions.append("country = ?")
|
||||
params.append(countries[0])
|
||||
elif len(countries) > 1:
|
||||
placeholders = ','.join(['?' for _ in countries])
|
||||
where_conditions.append(f"country IN ({placeholders})")
|
||||
params.extend(countries)
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
400, "Invalid date format. Use ISO format (YYYY-MM-DD)")
|
||||
if not all_dates and (from_ or to_):
|
||||
if not from_:
|
||||
from_ = "2025-01-01" # Default start date
|
||||
if not to_:
|
||||
to_ = datetime.now(timezone.utc).strftime("%Y-%m-%d") # Default to today
|
||||
|
||||
# Parse and convert dates
|
||||
from_date_naive = datetime.fromisoformat(from_)
|
||||
to_date_naive = datetime.fromisoformat(to_)
|
||||
|
||||
from_date = from_date_naive.replace(tzinfo=client_tz)
|
||||
to_date = to_date_naive.replace(tzinfo=client_tz)
|
||||
|
||||
# Include the entire end date
|
||||
to_date = to_date.replace(hour=23, minute=59, second=59)
|
||||
|
||||
from_ts = int(from_date.timestamp())
|
||||
to_ts = int(to_date.timestamp())
|
||||
|
||||
where_conditions.append("published BETWEEN ? AND ?")
|
||||
params.extend([from_ts, to_ts])
|
||||
|
||||
logger.info(f"Date range: {from_date} to {to_date} (UTC timestamps: {from_ts} to {to_ts})")
|
||||
|
||||
# Build the complete SQL query
|
||||
base_sql = """
|
||||
SELECT id, title, summary, url, published, country, created_at
|
||||
FROM news \
|
||||
"""
|
||||
|
||||
if where_conditions:
|
||||
sql = base_sql + " WHERE " + " AND ".join(where_conditions)
|
||||
else:
|
||||
sql = base_sql
|
||||
|
||||
sql += " ORDER BY published DESC LIMIT 1000"
|
||||
|
||||
# Log query info
|
||||
if all_countries and all_dates:
|
||||
logger.info("Querying ALL news articles (no filters)")
|
||||
elif all_countries:
|
||||
logger.info(f"Querying news from ALL countries with date filter")
|
||||
elif all_dates:
|
||||
logger.info(f"Querying ALL dates for countries: {country}")
|
||||
else:
|
||||
logger.info(f"Querying news: countries={country}, timezone={timezone_name}")
|
||||
|
||||
logger.info(f"SQL: {sql}")
|
||||
logger.info(f"Parameters: {params}")
|
||||
|
||||
# Execute the query
|
||||
db.execute(sql, params)
|
||||
rows = db.fetchall()
|
||||
result = [dict(row) for row in rows]
|
||||
|
||||
logger.info(f"Found {len(result)} news articles")
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Date parsing error: {e}")
|
||||
raise HTTPException(400, "Invalid date format. Use ISO format (YYYY-MM-DD)")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error fetching news: {e}")
|
||||
raise HTTPException(
|
||||
500, "Internal server error while fetching news"
|
||||
)
|
||||
raise HTTPException(500, "Internal server error while fetching news")
|
||||
|
||||
|
||||
@app.get("/feeds", response_model=List[Dict[str, Any]])
|
||||
@@ -125,7 +192,7 @@ async def list_feeds(db: sqlite3.Cursor = Depends(get_db)):
|
||||
"""
|
||||
try:
|
||||
db.execute("SELECT * FROM feeds ORDER BY country, url")
|
||||
return [dict(row) for row in db.fetchall()]
|
||||
return JSONResponse(content=[dict(row) for row in db.fetchall()])
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error fetching feeds: {e}")
|
||||
raise HTTPException(
|
||||
@@ -133,23 +200,6 @@ async def list_feeds(db: sqlite3.Cursor = Depends(get_db)):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/meta/last-sync", response_model=TimestampResponse)
|
||||
async def get_last_sync(db: sqlite3.Cursor = Depends(get_db)):
|
||||
"""
|
||||
Get the timestamp of the last successful feed synchronization.
|
||||
|
||||
Args:
|
||||
db: Database cursor dependency
|
||||
|
||||
Returns:
|
||||
Object containing the timestamp as a Unix epoch
|
||||
"""
|
||||
db.execute("SELECT val FROM meta WHERE key='last_sync'")
|
||||
row = db.fetchone()
|
||||
if row is None:
|
||||
import time
|
||||
return {"ts": int(time.time())}
|
||||
return {"ts": int(row["val"])}
|
||||
|
||||
|
||||
@app.post("/feeds", response_model=SuccessResponse)
|
||||
@@ -271,6 +321,25 @@ async def manual_sync(db: sqlite3.Cursor = Depends(get_db)):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/meta/last-sync", response_model=TimestampResponse)
|
||||
async def get_last_sync(db: sqlite3.Cursor = Depends(get_db)):
|
||||
"""
|
||||
Get the timestamp of the last successful feed synchronization.
|
||||
|
||||
Args:
|
||||
db: Database cursor dependency
|
||||
|
||||
Returns:
|
||||
Object containing the timestamp as a Unix epoch
|
||||
"""
|
||||
db.execute("SELECT val FROM meta WHERE key='last_sync'")
|
||||
row = db.fetchone()
|
||||
if row is None:
|
||||
import time
|
||||
return {"ts": int(time.time())}
|
||||
return {"ts": int(row["val"])}
|
||||
|
||||
|
||||
@app.get("/settings/cron", response_model=HoursResponse)
|
||||
async def get_cron_schedule(db: sqlite3.Cursor = Depends(get_db)):
|
||||
"""
|
||||
|
@@ -4,11 +4,11 @@
|
||||
CREATE TABLE IF NOT EXISTS news (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
summary TEXT,
|
||||
url TEXT NOT NULL,
|
||||
published TIMESTAMP NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
-- Index for faster queries on published date
|
||||
|
@@ -126,7 +126,7 @@ class NewsFetcher:
|
||||
def build_prompt(
|
||||
url: str,
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
summary: str = "",
|
||||
content: str = "") -> str:
|
||||
"""
|
||||
Generate a prompt for the LLM to summarize an article.
|
||||
@@ -134,7 +134,7 @@ class NewsFetcher:
|
||||
Args:
|
||||
url: Public URL of the article to summarize
|
||||
title: Article title from RSS feed (optional)
|
||||
description: Article description from RSS feed (optional)
|
||||
summary: Article summary from RSS feed (optional)
|
||||
content: Extracted article content (optional)
|
||||
|
||||
Returns:
|
||||
@@ -144,8 +144,8 @@ class NewsFetcher:
|
||||
context_info = []
|
||||
if title:
|
||||
context_info.append(f"RSS-Titel: {title}")
|
||||
if description:
|
||||
context_info.append(f"RSS-Beschreibung: {description}")
|
||||
if summary:
|
||||
context_info.append(f"RSS-Beschreibung: {summary}")
|
||||
if content:
|
||||
content_preview = content[:500] + \
|
||||
"..." if len(content) > 500 else content
|
||||
@@ -164,9 +164,9 @@ class NewsFetcher:
|
||||
"2. Falls kein Artikel-Inhalt verfügbar ist, nutze RSS-Titel und -Beschreibung\n"
|
||||
"3. Falls keine ausreichenden Informationen vorliegen, erstelle eine plausible Zusammenfassung basierend auf der URL\n"
|
||||
"4. Gib ausschließlich **gültiges minifiziertes JSON** zurück – kein Markdown, keine Kommentare\n"
|
||||
"5. Struktur: {\"title\":\"…\",\"description\":\"…\"}\n"
|
||||
"5. Struktur: {\"title\":\"…\",\"summary\":\"…\"}\n"
|
||||
"6. title: Aussagekräftiger deutscher Titel (max 100 Zeichen)\n"
|
||||
"7. description: Deutsche Zusammenfassung (zwischen 100 und 160 Wörter)\n"
|
||||
"7. summary: Deutsche Zusammenfassung (zwischen 100 und 160 Wörter)\n"
|
||||
"8. Kein Text vor oder nach dem JSON\n\n"
|
||||
"### Ausgabe\n"
|
||||
"Jetzt antworte mit dem JSON:"
|
||||
@@ -177,7 +177,7 @@ class NewsFetcher:
|
||||
client: httpx.AsyncClient,
|
||||
url: str,
|
||||
title: str = "",
|
||||
description: str = ""
|
||||
summary: str = ""
|
||||
) -> Optional[ArticleSummary]:
|
||||
"""
|
||||
Generate a summary of an article using the LLM.
|
||||
@@ -187,7 +187,7 @@ class NewsFetcher:
|
||||
client: An active httpx AsyncClient for making requests
|
||||
url: URL of the article to summarize
|
||||
title: Article title from RSS feed
|
||||
description: Article description from RSS feed
|
||||
summary: Article summary from RSS feed
|
||||
|
||||
Returns:
|
||||
A dictionary containing the article title and summaries in German and English,
|
||||
@@ -200,7 +200,7 @@ class NewsFetcher:
|
||||
f"⚠️ Could not fetch article content, using RSS data only")
|
||||
|
||||
prompt = NewsFetcher.build_prompt(
|
||||
url, title, description, article_content)
|
||||
url, title, summary, article_content)
|
||||
payload = {
|
||||
"model": LLM_MODEL,
|
||||
"prompt": prompt,
|
||||
@@ -226,7 +226,7 @@ class NewsFetcher:
|
||||
summary_data = llm_response
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ["title", "description"]
|
||||
required_fields = ["title", "summary"]
|
||||
missing_fields = [
|
||||
field for field in required_fields if field not in summary_data]
|
||||
|
||||
@@ -237,12 +237,12 @@ class NewsFetcher:
|
||||
return None
|
||||
|
||||
# Check summary quality metrics
|
||||
description = len(summary_data.get("description", "").split())
|
||||
summary_length = len(summary_data.get("summary", "").split())
|
||||
|
||||
if description > 160 or description < 100:
|
||||
if summary_length > 160:
|
||||
logger.warning(
|
||||
f"⚠️ Summary exceeds word limit - "
|
||||
f"Description: {description}/160"
|
||||
f"Summary: {summary_length}/160"
|
||||
)
|
||||
|
||||
return cast(ArticleSummary, summary_data)
|
||||
@@ -373,7 +373,7 @@ class NewsFetcher:
|
||||
f"⚠️ Database check failed for article {i}, continuing: {db_error}")
|
||||
|
||||
rss_title = getattr(entry, 'title', '')
|
||||
rss_description = getattr(
|
||||
rss_summary = getattr(
|
||||
entry, 'description', '') or getattr(
|
||||
entry, 'summary', '')
|
||||
|
||||
@@ -381,9 +381,11 @@ class NewsFetcher:
|
||||
client,
|
||||
article_url,
|
||||
title=rss_title,
|
||||
description=rss_description
|
||||
summary=rss_summary
|
||||
)
|
||||
|
||||
logger.info(summary)
|
||||
|
||||
if not summary:
|
||||
logger.warning(
|
||||
f"❌ Failed to get summary for article {i}: {article_url}")
|
||||
@@ -398,12 +400,12 @@ class NewsFetcher:
|
||||
"""
|
||||
INSERT
|
||||
OR IGNORE INTO news
|
||||
(title, description, url, published, country)
|
||||
(title, summary, url, published, country)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
summary["title"],
|
||||
summary["description"],
|
||||
summary["summary"],
|
||||
article_url,
|
||||
published_timestamp,
|
||||
feed_row["country"],
|
||||
|
Reference in New Issue
Block a user