Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
00a3da7
Stubbing in AI filters in menu
samuelclay May 13, 2025
343ea72
Stubbing in MPromptClassifier. Untested.
samuelclay May 28, 2025
5c20595
Merge branch 'master' into ai-filters
samuelclay Oct 7, 2025
38f2622
Consolidating into Train & Filter. Needs text classifier and prompt c…
samuelclay Oct 7, 2025
6208e46
Merge branch 'main' into ai-filters
samuelclay Oct 20, 2025
5648c06
Cleanup
samuelclay Oct 20, 2025
524b66d
Fix discover indexing completion to reload open discover view
samuelclay Oct 20, 2025
5ca9ebe
Adding feed tags and authors to story trainer.
samuelclay Oct 21, 2025
c3a31b7
Adding Write Rule sideoption.
samuelclay Oct 21, 2025
c822131
Changes auto-committed by Conductor
samuelclay Oct 21, 2025
38e1edb
Changes auto-committed by Conductor
samuelclay Oct 21, 2025
cbebca2
Changes auto-committed by Conductor
samuelclay Oct 21, 2025
b33ca8d
Merge pull request #1984 from samuelclay/create-script
samuelclay Oct 21, 2025
a14050c
Merge branch 'main' into ai-filters
samuelclay Oct 21, 2025
51dacb3
Moving .conductor.json
samuelclay Oct 21, 2025
767cfcf
Fixing typo
samuelclay Oct 21, 2025
45e83c7
Fix Conductor workspace setup and run scripts
samuelclay Oct 21, 2025
1373de1
DEBUG_ASSETS means expanding globs manually.
samuelclay Oct 21, 2025
2574ecb
Fixing container logging on conductor run
samuelclay Oct 21, 2025
7336e63
Revising AGENTS.md about make nb
samuelclay Oct 21, 2025
8bf8f15
Conductor specific instructions on urls.
samuelclay Oct 21, 2025
589dcdd
Better logging for improvmx newsletters so we can figure out why new …
samuelclay Oct 21, 2025
143f7c6
Updated the validation to check for fields that are actually present …
samuelclay Oct 21, 2025
fdfdc66
Merge branch 'main' into ai-filters
samuelclay Oct 21, 2025
02d123b
Worktree workflow adapted from Conductor.
samuelclay Oct 21, 2025
fdfea8c
make worktree-log
samuelclay Oct 21, 2025
80399da
make worktree-stop
samuelclay Oct 21, 2025
be1a48a
Fixing make worktree-log
samuelclay Oct 21, 2025
55a33f8
No need to worktree debug
samuelclay Oct 21, 2025
805884d
Ensuring make keys is run on worktrees
samuelclay Oct 21, 2025
9822c43
Fixing make worktree-log to use the project for logging
samuelclay Oct 21, 2025
1a183d3
Stop existing containers
samuelclay Oct 22, 2025
9984b36
Don't compress local static files
samuelclay Oct 22, 2025
a71561e
Adding more improvmx debug logging for newsletters.
samuelclay Oct 22, 2025
c07a104
Merge branch 'main' into ai-filters
samuelclay Oct 22, 2025
3ca55a7
Adding Text highlight. Now need the dialog.
samuelclay Oct 22, 2025
37ed4bc
Merge branch 'main' into ai-filters
samuelclay Oct 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .conductor/conductor-run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/bash
# Thin wrapper around worktree-dev.sh for Conductor compatibility
# Runs setup if needed and follows logs

exec "$(dirname "$0")/../worktree-dev.sh" "$@"
5 changes: 5 additions & 0 deletions .conductor/conductor-setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/bash
# Thin wrapper around worktree-dev.sh for Conductor compatibility
# Runs setup and exits without following logs

exec "$(dirname "$0")/../worktree-dev.sh" --setup-only
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ config/mongodb_keyfile.key
# Docker Jinja templates
docker/haproxy/haproxy.consul.cfg
# docker/haproxy/haproxy.staging.cfg # Staging doesn't use jinja templates, so no need to ignore
# Conductor workspace files (auto-generated by .conductor/conductor-setup.sh)
.conductor/docker-compose.*.yml
.conductor/haproxy/
# Ignore all subdirs in .conductor
.conductor/**/*

# Worktree development files (auto-generated by worktree-dev.sh)
.worktree/
docker/nginx/nginx.consul.conf
docker/prometheus/prometheus.yml
docker/redis/redis_replica.conf
Expand Down
21 changes: 13 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# NewsBlur Development Guidelines

## Git Worktree Development
- **Use git worktrees for parallel development**: Run `make worktree` in a worktree to start workspace-specific services
- Main repo uses standard ports (80/443), worktrees get unique ports based on directory name hash
- Run `./worktree-dev.sh` to see your workspace's assigned ports (output shows all URLs)
- Close worktree: `make worktree-close` stops containers and removes worktree if clean (no uncommitted changes)
- All worktrees share the same database services (postgres, mongo, redis, elasticsearch)

## Build & Test Commands
- `make nb` - Build and start all services (ONLY use for initial setup, not during development)
- `make bounce` - Restart all containers with new images
Expand All @@ -10,11 +17,6 @@
- `make test` - Run all tests (defaults: SCOPE=apps, ARGS="--noinput -v 1 --failfast")
- `make test SCOPE=apps.rss_feeds ARGS="-v 2"`

**IMPORTANT: Do NOT run `make nb` during development!**
- Web and Node servers restart automatically when code changes
- Task/Celery server must be manually restarted only when working on background tasks
- Running `make nb` unnecessarily rebuilds everything and wastes time

Note: All docker commands must use `-t` instead of `-it` to avoid interactive mode issues when running through Claude.

## Python Environment
Expand Down Expand Up @@ -69,10 +71,13 @@ Server names are defined in `ansible/inventories/hetzner.ini`. Common server pre
- With POST data: `make api URL=/reader/river_stories ARGS="-X POST -d 'feeds[]=1&feeds[]=2&feeds[]=3'"`

## Browser Testing with Chrome DevTools MCP
- Local dev: `https://localhost`
- Local dev: `https://localhost` (when using containers directly)
- Open All Site Stories: `NEWSBLUR.reader.open_river_stories()`
- Open feed: `NEWSBLUR.reader.open_feed(feedId)`
- Get feed with unread stories: `NEWSBLUR.assets.feeds.find(f => f.get('nt') > 0)`
- Open feed: `NEWSBLUR.reader.open_feed(feed.get('id'))`
- Select first story: `document.querySelector('.NB-feed-story').click()`
- Open story intelligence trainer: `document.querySelector('.NB-feed-story-train').click()`
- Open feed options popover: Click `.NB-feedbar-options` element (no API)
- Get feed IDs: `NEWSBLUR.assets.feeds` is a Backbone.js collection with underscore.js operations. E.g. `var feedId = NEWSBLUR.assets.feeds.find((e) => e.get('nt') > 0).get('id')` for first feed with neutral unread stories
- Get feed IDs: `NEWSBLUR.assets.feeds` is a Backbone.js collection with underscore.js operations
- Open folder: Click `.folder .folder_title` element (no API)
- **Screenshots**: Always specify `filePath: "/tmp/newsblur-screenshot.png"` to avoid permission prompts
74 changes: 59 additions & 15 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,46 @@ bootstrap:

nbup:
docker compose up -d --build --remove-orphans

# Git worktree development workflow
worktree:
./worktree-dev.sh

worktree-log:
@WORKSPACE_NAME=$$(basename "$$(pwd)"); \
if [ -f ".worktree/docker-compose.$${WORKSPACE_NAME}.yml" ]; then \
COMPOSE_PROJECT_NAME="$$WORKSPACE_NAME" docker compose -f ".worktree/docker-compose.$${WORKSPACE_NAME}.yml" logs -f --tail 20 newsblur_web newsblur_node; \
else \
echo "No worktree configuration found. Run 'make worktree' first."; \
fi

worktree-stop:
@WORKSPACE_NAME=$$(basename "$$(pwd)"); \
echo "Stopping workspace: $$WORKSPACE_NAME"; \
if [ -f ".worktree/docker-compose.$${WORKSPACE_NAME}.yml" ]; then \
docker compose -f ".worktree/docker-compose.$${WORKSPACE_NAME}.yml" down --remove-orphans; \
echo "✓ Stopped containers for workspace: $$WORKSPACE_NAME"; \
else \
echo "No worktree configuration found"; \
fi

worktree-close: worktree-stop
@if [ -f ".git" ]; then \
echo "Detected git worktree"; \
if [ -z "$$(git status --porcelain)" ]; then \
WORKTREE_PATH=$$(pwd); \
cd ..; \
echo "Removing worktree: $$WORKTREE_PATH"; \
git worktree remove "$$WORKTREE_PATH"; \
echo "✓ Removed worktree"; \
else \
echo "⚠ Worktree has uncommitted changes. Commit or stash changes before closing."; \
git status --short; \
fi; \
else \
echo "Not in a worktree, keeping directory"; \
fi

coffee:
coffee -c -w **/*.coffee
migrations:
Expand Down Expand Up @@ -102,22 +142,26 @@ test-river:
docker compose exec -T newsblur_web python3 manage.py test apps.reader.test_river_stories --noinput -v 2

keys:
mkdir -p config/certificates
openssl dhparam -out config/certificates/dhparam-2048.pem 2048
openssl req -x509 -nodes -new -sha256 -days 1024 -newkey rsa:2048 -keyout config/certificates/RootCA.key -out config/certificates/RootCA.pem -subj "/C=US/CN=Example-Root-CA"
openssl x509 -outform pem -in config/certificates/RootCA.pem -out config/certificates/RootCA.crt
openssl req -new -nodes -newkey rsa:2048 -keyout config/certificates/localhost.key -out config/certificates/localhost.csr -subj "/C=US/ST=YourState/L=YourCity/O=Example-Certificates/CN=localhost"
openssl x509 -req -sha256 -days 1024 -in config/certificates/localhost.csr -CA config/certificates/RootCA.pem -CAkey config/certificates/RootCA.key -CAcreateserial -out config/certificates/localhost.crt
cat config/certificates/localhost.crt config/certificates/localhost.key > config/certificates/localhost.pem
@if [ "$$(uname)" = "Darwin" ]; then \
sudo /usr/bin/security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ./config/certificates/RootCA.crt; \
elif [ "$$(uname)" = "Linux" ]; then \
echo "Installing certificate for Linux..."; \
sudo cp ./config/certificates/RootCA.crt /usr/local/share/ca-certificates/newsblur-rootca.crt || true; \
sudo update-ca-certificates || true; \
echo "Certificate installation attempted. If this fails, you may need to manually trust the certificate."; \
@if [ -f "config/certificates/localhost.pem" ]; then \
echo "SSL certificates already exist"; \
else \
echo "Unknown OS. Please manually trust the certificate at ./config/certificates/RootCA.crt"; \
mkdir -p config/certificates; \
openssl dhparam -out config/certificates/dhparam-2048.pem 2048; \
openssl req -x509 -nodes -new -sha256 -days 1024 -newkey rsa:2048 -keyout config/certificates/RootCA.key -out config/certificates/RootCA.pem -subj "/C=US/CN=Example-Root-CA"; \
openssl x509 -outform pem -in config/certificates/RootCA.pem -out config/certificates/RootCA.crt; \
openssl req -new -nodes -newkey rsa:2048 -keyout config/certificates/localhost.key -out config/certificates/localhost.csr -subj "/C=US/ST=YourState/L=YourCity/O=Example-Certificates/CN=localhost"; \
openssl x509 -req -sha256 -days 1024 -in config/certificates/localhost.csr -CA config/certificates/RootCA.pem -CAkey config/certificates/RootCA.key -CAcreateserial -out config/certificates/localhost.crt; \
cat config/certificates/localhost.crt config/certificates/localhost.key > config/certificates/localhost.pem; \
if [ "$$(uname)" = "Darwin" ]; then \
sudo /usr/bin/security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ./config/certificates/RootCA.crt; \
elif [ "$$(uname)" = "Linux" ]; then \
echo "Installing certificate for Linux..."; \
sudo cp ./config/certificates/RootCA.crt /usr/local/share/ca-certificates/newsblur-rootca.crt || true; \
sudo update-ca-certificates || true; \
echo "Certificate installation attempted. If this fails, you may need to manually trust the certificate."; \
else \
echo "Unknown OS. Please manually trust the certificate at ./config/certificates/RootCA.crt"; \
fi; \
fi

# Doesn't work yet
Expand Down
165 changes: 164 additions & 1 deletion apps/analyzer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from apps.analyzer.tasks import EmailPopularityQuery
from apps.rss_feeds.models import Feed
from utils import log as logging
from utils.ai_functions import classify_stories_with_ai


class FeatureCategory(models.Model):
Expand Down Expand Up @@ -166,14 +167,27 @@ def __str__(self):
return "%s - %s/%s: (%s) %s" % (user, self.feed_id, self.social_user_id, self.score, feed)


def compute_story_score(story, classifier_titles, classifier_authors, classifier_tags, classifier_feeds):
def compute_story_score(
story, classifier_titles, classifier_authors, classifier_tags, classifier_feeds, prompt_score=None
):
intelligence = {
"feed": apply_classifier_feeds(classifier_feeds, story["story_feed_id"]),
"author": apply_classifier_authors(classifier_authors, story),
"tags": apply_classifier_tags(classifier_tags, story),
"title": apply_classifier_titles(classifier_titles, story),
}

# Include AI prompt classifier score if provided
if prompt_score is not None:
intelligence["prompt"] = prompt_score

score = 0

# If we have a prompt score, it takes priority
if "prompt" in intelligence and intelligence["prompt"] != 0:
return intelligence["prompt"]

# Otherwise use the traditional classifier logic
score_max = max(intelligence["title"], intelligence["author"], intelligence["tags"])
score_min = min(intelligence["title"], intelligence["author"], intelligence["tags"])
if score_max > 0:
Expand Down Expand Up @@ -245,6 +259,155 @@ def apply_classifier_feeds(classifiers, feed, social_user_ids=None):
return 0


class MPromptClassifier(mongo.Document):
user_id = mongo.IntField()
feed_id = mongo.IntField(default=0) # 0 means applies to folder level
folder_id = mongo.StringField(default="") # Empty string means applies to feed level
prompt = mongo.StringField()
classifier_type = mongo.StringField(choices=["focus", "hidden"])
creation_date = mongo.DateTimeField(default=datetime.datetime.now)

meta = {
"collection": "prompt_classifier",
"indexes": [("user_id", "feed_id"), ("user_id", "folder_id")],
"allow_inheritance": False,
}

def __str__(self):
user = User.objects.get(pk=self.user_id)
target = f"Feed: {self.feed_id}" if self.feed_id else f"Folder: {self.folder_id}"
return f"{user} - {target}: ({self.classifier_type}) {self.prompt[:50]}..."

@classmethod
def get_prompts_for_user(cls, user_id, feed_ids=None, folder_ids=None):
"""
Get all applicable prompt classifiers for a user and specific feeds/folders.

Args:
user_id: The ID of the user
feed_ids: Optional list of feed IDs to filter by
folder_ids: Optional list of folder IDs to filter by

Returns:
Dictionary with feed_id/folder_id keys and lists of prompts as values
"""
params = {"user_id": user_id}

# Get feed-specific prompts
feed_prompts = {}
if feed_ids:
params["feed_id__in"] = feed_ids + [0] # Include feed-specific and global prompts
feed_classifiers = list(cls.objects.filter(**params))

# Group by feed_id
for prompt in feed_classifiers:
if prompt.feed_id not in feed_prompts:
feed_prompts[prompt.feed_id] = []
feed_prompts[prompt.feed_id].append(prompt)

# Get folder-specific prompts
folder_prompts = {}
if folder_ids:
params["folder_id__in"] = folder_ids + [""] # Include folder-specific and global prompts
folder_classifiers = list(cls.objects.filter(**params))

# Group by folder_id
for prompt in folder_classifiers:
if prompt.folder_id not in folder_prompts:
folder_prompts[prompt.folder_id] = []
folder_prompts[prompt.folder_id].append(prompt)

return {"feed_prompts": feed_prompts, "folder_prompts": folder_prompts}

@classmethod
def classify_stories(cls, user_id, stories, feed_ids=None, folder_ids=None):
"""
Apply AI-based classification to a list of stories based on user's prompts.

Args:
user_id: The ID of the user
stories: List of story dictionaries
feed_ids: Optional list of feed IDs the stories belong to
folder_ids: Optional list of folder IDs the stories belong to

Returns:
Dictionary mapping story_ids to scores (1 for focus, 0 for neutral, -1 for hidden)
"""
if not stories:
return {}

# Group stories by feed_id for efficient classification
stories_by_feed = defaultdict(list)
for story in stories:
stories_by_feed[story["story_feed_id"]].append(story)

# Get all applicable prompts
prompts = cls.get_prompts_for_user(user_id, feed_ids=feed_ids, folder_ids=folder_ids)
feed_prompts = prompts["feed_prompts"]
folder_prompts = prompts["folder_prompts"]

# Final classifications
classifications = {story["story_id"]: 0 for story in stories}

# Apply feed-specific prompts
for feed_id, feed_stories in stories_by_feed.items():
# Apply global prompts (feed_id=0)
if 0 in feed_prompts:
for prompt in feed_prompts[0]:
results = classify_stories_with_ai(prompt, feed_stories)
cls._update_classifications(classifications, results, prompt.classifier_type)

# Apply feed-specific prompts
if feed_id in feed_prompts:
for prompt in feed_prompts[feed_id]:
results = classify_stories_with_ai(prompt, feed_stories)
cls._update_classifications(classifications, results, prompt.classifier_type)

# Apply folder-specific prompts if we have folder_ids
if folder_ids and folder_prompts:
for folder_id in folder_ids:
if folder_id in folder_prompts:
# Find stories that belong to feeds in this folder
folder_stories = []
for feed_id, feed_stories in stories_by_feed.items():
# We would need to check if feed_id belongs to folder_id here
# For simplicity, we'll just apply to all stories
# In a real implementation, you'd use a feed_folder mapping
folder_stories.extend(feed_stories)

# Apply folder prompts to eligible stories
for prompt in folder_prompts[folder_id]:
results = classify_stories_with_ai(prompt, folder_stories)
cls._update_classifications(classifications, results, prompt.classifier_type)

return classifications

@classmethod
def _update_classifications(cls, classifications, results, classifier_type):
"""
Update the classification dictionary based on new results and classifier type.

Args:
classifications: Dictionary to update
results: New classification results
classifier_type: Type of classifier ("focus" or "hidden")

Returns:
Updated classifications dictionary
"""
for story_id, result in results.items():
# Only update if the AI gave a non-neutral classification
if result != 0:
# For "focus" classifiers, only accept positive scores (1)
if classifier_type == "focus" and result > 0:
classifications[story_id] = 1
# For "hidden" classifiers, only accept negative scores (-1)
elif classifier_type == "hidden" and result < 0:
classifications[story_id] = -1

return classifications


def get_classifiers_for_user(
user,
feed_id=None,
Expand Down
6 changes: 6 additions & 0 deletions conductor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"scripts": {
"setup": "./.conductor/conductor-setup.sh",
"run": "./.conductor/conductor-run.sh"
}
}
Loading