|
|
import os |
|
|
import sys |
|
|
import shutil |
|
|
import click |
|
|
from datetime import datetime |
|
|
from typing import List |
|
|
|
|
|
|
|
|
|
|
|
from src.config import AppConfig |
|
|
from src.hn_mood_reader import HnMoodReader, FeedEntry |
|
|
from src.vibe_logic import VIBE_THRESHOLDS |
|
|
|
|
|
|
|
|
|
|
|
def get_status_text_and_color(score: float) -> (str, str): |
|
|
""" |
|
|
Determines the plain text status and a corresponding color for a given score. |
|
|
""" |
|
|
clamped_score = max(0.0, min(1.0, score)) |
|
|
|
|
|
|
|
|
color_map = { |
|
|
"VIBE:HIGH": "green", |
|
|
"VIBE:GOOD": "cyan", |
|
|
"VIBE:FLAT": "yellow", |
|
|
"VIBE:LOW": "red" |
|
|
} |
|
|
|
|
|
for threshold in VIBE_THRESHOLDS: |
|
|
if clamped_score >= threshold.score: |
|
|
status = threshold.status.split(" ")[-1].replace(' ', '') |
|
|
return status, color_map.get(status, "white") |
|
|
|
|
|
|
|
|
status = VIBE_THRESHOLDS[-1].status.split(" ")[-1].replace(' ', '') |
|
|
return status, color_map.get(status, "white") |
|
|
|
|
|
def initialize_reader(model_name: str) -> HnMoodReader: |
|
|
""" |
|
|
Initializes the HnMoodReader instance with the specified model. |
|
|
Exits the script if the model fails to load. |
|
|
""" |
|
|
click.echo(f"Initializing mood reader with model: '{model_name}'...", err=True) |
|
|
try: |
|
|
reader = HnMoodReader(model_name=model_name) |
|
|
click.secho("β
Model loaded successfully.", fg="green", err=True) |
|
|
return reader |
|
|
except Exception as e: |
|
|
click.secho(f"β FATAL: Could not initialize model '{model_name}'.", fg="red", err=True) |
|
|
click.secho(f" Error: {e}", fg="red", err=True) |
|
|
sys.exit(1) |
|
|
|
|
|
def display_feed(scored_entries: List[FeedEntry], top: int, offset: int, model_name: str): |
|
|
"""Clears the screen and displays the current slice of the feed.""" |
|
|
click.clear() |
|
|
|
|
|
|
|
|
|
|
|
try: |
|
|
terminal_width = shutil.get_terminal_size()[0] |
|
|
except OSError: |
|
|
terminal_width = 80 |
|
|
|
|
|
click.echo(f"π° Hacker News Mood Reader") |
|
|
click.echo(f" Model: {model_name}") |
|
|
click.echo(f" Showing {offset + 1}-{min(offset + top, len(scored_entries))} of {len(scored_entries)} stories") |
|
|
click.secho("=" * terminal_width, fg="blue") |
|
|
|
|
|
header = f"{'VIBE':<5} | {'SCORE':<7} | {'PUBLISHED':<16} | {'TITLE'}" |
|
|
click.secho(header, bold=True) |
|
|
click.secho("-" * terminal_width, fg="blue") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fixed_width = 35 |
|
|
max_title_width = terminal_width - fixed_width |
|
|
|
|
|
|
|
|
if not scored_entries: |
|
|
click.echo("No entries found in the feed.") |
|
|
else: |
|
|
|
|
|
for entry in scored_entries[offset:offset + top]: |
|
|
status, color = get_status_text_and_color(entry.mood.raw_score) |
|
|
|
|
|
|
|
|
|
|
|
truncated_status = status[5:] |
|
|
vibe_part = click.style(f"{truncated_status:<5}", fg=color) |
|
|
|
|
|
score_part = f"| {entry.mood.raw_score:>.4f} " |
|
|
published_part = f"| {entry.published_time_str:<16} | " |
|
|
|
|
|
|
|
|
full_title = entry.title |
|
|
|
|
|
if len(full_title) > max_title_width: |
|
|
|
|
|
title_part = full_title[:max_title_width - 3] + "..." |
|
|
else: |
|
|
title_part = full_title |
|
|
|
|
|
|
|
|
|
|
|
full_line = vibe_part + score_part + published_part + title_part |
|
|
click.echo(full_line) |
|
|
|
|
|
click.secho("-" * terminal_width, fg="blue") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@click.command() |
|
|
@click.option( |
|
|
"-m", "--model", |
|
|
help="Name of the Sentence Transformer model from Hugging Face. Overrides MOOD_MODEL env var.", |
|
|
default=None, |
|
|
show_default=False |
|
|
) |
|
|
@click.option( |
|
|
"-n", "--top", |
|
|
help="Number of stories to display on screen at once.", |
|
|
default=15, |
|
|
type=int, |
|
|
show_default=True |
|
|
) |
|
|
def main(model, top): |
|
|
""" |
|
|
Fetch and display Hacker News stories scored by a sentence-embedding model. |
|
|
Runs continuously. Use arrow keys to scroll, [SPACE] to refresh, [q] to quit. |
|
|
""" |
|
|
|
|
|
model_name = model or os.environ.get("MOOD_MODEL") or AppConfig.DEFAULT_MOOD_READER_MODEL |
|
|
reader = initialize_reader(model_name) |
|
|
scored_entries: List[FeedEntry] = [] |
|
|
scroll_offset = 0 |
|
|
|
|
|
|
|
|
click.echo("Fetching initial feed...", err=True) |
|
|
try: |
|
|
scored_entries = reader.fetch_and_score_feed() |
|
|
except Exception as e: |
|
|
click.secho(f"β ERROR: Initial fetch failed: {e}", fg="red", err=True) |
|
|
|
|
|
|
|
|
while True: |
|
|
display_feed(scored_entries, top, scroll_offset, reader.model_name) |
|
|
|
|
|
click.secho("Use [β|β] to scroll, [SPACE] to refresh, or [q] to quit.", bold=True, err=True) |
|
|
key = click.getchar() |
|
|
|
|
|
if key == ' ': |
|
|
click.echo("Refreshing feed...", err=True) |
|
|
try: |
|
|
scored_entries = reader.fetch_and_score_feed() |
|
|
scroll_offset = 0 |
|
|
except Exception as e: |
|
|
click.secho(f"β ERROR: Refresh failed: {e}", fg="red", err=True) |
|
|
continue |
|
|
|
|
|
elif key in ('q', 'Q'): |
|
|
click.echo("Exiting.") |
|
|
break |
|
|
|
|
|
|
|
|
elif key == '\x1b[A': |
|
|
scroll_offset = max(0, scroll_offset - 1) |
|
|
elif key == '\x1b[B': |
|
|
|
|
|
scroll_offset = min(scroll_offset + 1, max(0, len(scored_entries) - top)) |
|
|
|
|
|
if __name__ == "__main__": |
|
|
main() |
|
|
|
|
|
|
|
|
|