Skip to content

Commit 5d83c6b

Browse files
author
ma111e
committed
feat: add per-step LLM provider configuration
Allow routing each article analysis task and digest step to a distinct provider/model pair via step_providers map in analysis config. Enables fine-tuning cost vs. quality per step. - Add StepProviderOverride struct for provider+model pairs - Add StepProviders field to AnalysisConfig and EffectiveEditorial - Add resolveStepProvider() helper for step-level resolution - Refactor article analysis pipeline to support provider switching: resets conversation history when provider changes, resends article at the start of each new provider session - Update digest pipeline call sites (dedupe, digest_summary, glossary_entities, glossary_context) to resolve step providers Steps without overrides fall back to analysis.provider. Model-only overrides use the default provider with the specified model. No changes to profiles or per-run override behavior.
1 parent 52898d7 commit 5d83c6b

29 files changed

Lines changed: 1215 additions & 68 deletions

cmd/dlk/feeds_build.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,13 @@ func resolveFeedURL(client *downlinkclient.DownlinkClient, rawURL string, header
266266

267267
discovered := diag.GetDiscoveredFeeds()
268268
if len(discovered) == 0 {
269-
return "", fmt.Errorf("no RSS/Atom feeds found on %s — pass a direct feed URL", rawURL)
269+
// No embedded feed: configure the HTML index page directly. Autoconfig's html
270+
// path discovers the post-link list and scrapes each linked article.
271+
if !jsonOutput {
272+
fmt.Printf("%s %s has no RSS/Atom feed; configuring it as an HTML index page.\n",
273+
styleWarn.Render("!"), rawURL)
274+
}
275+
return rawURL, nil
270276
}
271277

272278
if !jsonOutput {

cmd/server/internal/adminserver/server.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,10 @@ func refreshResultRank(r store.FeedRefreshResultView) int {
129129
return 0
130130
case r.ErrorCount > 0:
131131
return 1
132-
default:
132+
case r.WarningCount > 0:
133133
return 2
134+
default:
135+
return 3
134136
}
135137
}
136138

@@ -349,6 +351,7 @@ type refreshStats struct {
349351
Fetched int // total items fetched
350352
Stored int // total articles stored
351353
Failed int // per-feed refreshes that hit a top-level fetch error
354+
Warned int // item-level non-fatal notices (e.g. content sanitized)
352355
}
353356

354357
func buildRefreshStats(runs []store.FeedRefreshRunSummary) refreshStats {
@@ -358,6 +361,7 @@ func buildRefreshStats(runs []store.FeedRefreshRunSummary) refreshStats {
358361
s.Fetched += r.TotalFetched
359362
s.Stored += r.TotalStored
360363
s.Failed += r.FailCount
364+
s.Warned += r.WarningCount
361365
}
362366
return s
363367
}

cmd/server/internal/adminserver/templates/layout.tmpl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,13 @@
158158
.chip.u { color: var(--signal); background: #f2f9fe; border-color: #cfe7fa; }
159159
.chip.d { color: var(--slate); border-color: var(--border); }
160160
.chip.err { color: var(--ink); border-color: var(--border-warm); }
161+
.chip.warn { color: #b45309; background: #fffbeb; border-color: #fde68a; }
161162
.chip.na { color: var(--soft); }
162163
.call .body { padding: 6px 16px 18px; }
163164
.call h4 { margin: 16px 0 7px; font-size: 12px; font-weight: 500; letter-spacing: .04em; text-transform: uppercase; color: var(--slate); }
164165
pre.text { white-space: pre-wrap; word-break: break-word; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 13px 15px; margin: 0; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6; max-height: 480px; overflow: auto; color: var(--ink); }
165166
pre.text.err { color: var(--ink); border-color: var(--border-warm); }
167+
pre.text.warn { color: #92400e; border-color: #fde68a; background: #fffbeb; }
166168

167169
/* gentle page-load reveal */
168170
.reveal { opacity: 0; transform: translateY(8px); animation: revealIn .5s cubic-bezier(.2,.7,.3,1) forwards; }

cmd/server/internal/adminserver/templates/refresh.tmpl

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@
1111
<div class="section-label reveal" style="animation-delay:.08s;margin:24px 0 14px">Feeds</div>
1212

1313
{{range .Results}}
14-
<details class="call reveal"{{if or (not .Success) .ErrorCount}} open{{end}}>
14+
<details class="call reveal"{{if or (not .Success) .ErrorCount .WarningCount}} open{{end}}>
1515
<summary>
1616
<span class="label">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedId}}{{end}}</span>
1717
<span class="model">{{.FeedURL}}</span>
1818
{{if not .Success}}<span class="chip err">fetch failed</span>
1919
{{else if .ErrorCount}}<span class="chip err">{{.ErrorCount}} item error{{if gt .ErrorCount 1}}s{{end}}</span>
2020
{{else}}<span class="chip u">ok</span>{{end}}
21+
{{if .WarningCount}}<span class="chip warn">{{.WarningCount}} sanitized</span>{{end}}
2122
<span class="spacer"></span>
2223
<span class="model">{{comma .TotalFetched}} fetched · {{comma .Stored}} stored · {{comma .Skipped}} skipped</span>
2324
<span class="model">{{durMs .DurationMs}}</span>
@@ -29,7 +30,12 @@
2930
<pre class="text err">{{range .Errors}}{{.}}
3031
{{end}}</pre>
3132
{{end}}
32-
{{if and (not .FetchError) (not .Errors)}}
33+
{{if .Warnings}}
34+
<h4>Warnings ({{.WarningCount}})</h4>
35+
<pre class="text warn">{{range .Warnings}}{{.}}
36+
{{end}}</pre>
37+
{{end}}
38+
{{if and (not .FetchError) (not .Errors) (not .Warnings)}}
3339
<div class="muted mono" style="font-size:12px;padding:8px 0">No errors — {{comma .Stored}} stored, {{comma .Skipped}} skipped.</div>
3440
{{end}}
3541
{{if .RawBody}}

cmd/server/internal/adminserver/templates/refreshes.tmpl

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
<div class="l">Failed</div><div class="n">{{comma .Stats.Failed}}</div>
2323
<div class="sub">feed failures</div>
2424
</div>
25+
<div class="stat reveal" style="animation-delay:.22s">
26+
<div class="l">Sanitized</div><div class="n">{{comma .Stats.Warned}}</div>
27+
<div class="sub">items cleaned</div>
28+
</div>
2529
</div>
2630

2731
<div class="panel reveal" style="animation-delay:.24s">
@@ -36,6 +40,7 @@
3640
<th class="num">Fetched</th>
3741
<th class="num">Stored</th>
3842
<th class="num">Errors</th>
43+
<th class="num">Sanitized</th>
3944
</tr>
4045
</thead>
4146
<tbody>
@@ -49,9 +54,10 @@
4954
<td class="num up">{{comma .TotalFetched}}</td>
5055
<td class="num">{{comma .TotalStored}}</td>
5156
<td class="num {{if .FailCount}}down{{else}}muted{{end}}">{{if or .FailCount .ErrorCount}}{{add .FailCount .ErrorCount}}{{else}}—{{end}}</td>
57+
<td class="num muted">{{if .WarningCount}}{{comma .WarningCount}}{{else}}—{{end}}</td>
5258
</tr>
5359
{{else}}
54-
<tr><td colspan="8" class="empty">No feed refreshes recorded yet.</td></tr>
60+
<tr><td colspan="9" class="empty">No feed refreshes recorded yet.</td></tr>
5561
{{end}}
5662
</tbody>
5763
</table>

cmd/server/internal/manager/feedrefresh.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ func (m *FeedManager) RecordRefresh(runID string, feed models.Feed, fr models.Fe
5151
Stored: fr.Stored,
5252
Skipped: fr.Skipped,
5353
Errors: fr.Errors,
54+
Warnings: fr.Warnings,
5455
RawBody: fr.RawBody,
5556
RawStatus: fr.RawStatus,
5657
RawType: fr.RawContentType,

cmd/server/internal/manager/feeds.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,14 +234,16 @@ func (m *FeedManager) FetchFeed(feed models.Feed, from *time.Time, to *time.Time
234234
continue
235235
}
236236

237-
// Check if content is valid UTF-8
237+
// Sanitize invalid UTF-8 rather than dropping the article: a few bad bytes
238+
// (e.g. a stray Latin-1 0xe9) shouldn't cost the whole item. Strip the
239+
// invalid sequences and store the rest.
238240
if !utf8.ValidString(article.Content) {
239-
log.WithField("article", article.Id).Error("Article content is not valid UTF-8, skipping")
240-
result.Errors = append(result.Errors, fmt.Sprintf("%s: invalid UTF-8 content", item.Title))
241241
if trace.Enabled() {
242242
trace.Content(article.Id, article.Link, "invalid-utf8", article.Content)
243243
}
244-
continue
244+
article.Content = strings.ToValidUTF8(article.Content, "")
245+
log.WithField("article", article.Id).Warn("Article content had invalid UTF-8; stripped invalid bytes")
246+
result.Warnings = append(result.Warnings, fmt.Sprintf("%s: content had invalid UTF-8, stripped invalid bytes", item.Title))
245247
}
246248

247249
if err := m.store.StoreArticle(article); err != nil {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package manager
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
"time"
7+
"unicode/utf8"
8+
9+
"github.com/ma111e/downlink/cmd/server/internal/scrapers"
10+
"github.com/ma111e/downlink/cmd/server/internal/store"
11+
"github.com/ma111e/downlink/pkg/models"
12+
13+
"gorm.io/datatypes"
14+
)
15+
16+
// stubScraper returns a fixed set of items and never re-scrapes.
17+
type stubScraper struct {
18+
items []models.FeedItem
19+
}
20+
21+
func (s *stubScraper) Fetch(url string, params map[string]any) ([]models.FeedItem, *scrapers.RawResponse, error) {
22+
return s.items, &scrapers.RawResponse{}, nil
23+
}
24+
25+
func (s *stubScraper) ScrapeContent(url string, params map[string]any) (string, error) {
26+
return "", nil
27+
}
28+
29+
// FetchFeed should keep an article whose content has invalid UTF-8, storing it
30+
// with the invalid bytes stripped rather than dropping it as an error.
31+
func TestFetchFeed_SanitizesInvalidUTF8Content(t *testing.T) {
32+
db, err := store.New(filepath.Join(t.TempDir(), "test.db"))
33+
if err != nil {
34+
t.Fatalf("store.New: %v", err)
35+
}
36+
t.Cleanup(func() { _ = db.Close() })
37+
38+
m := NewFeedManager(db)
39+
m.RegisterScraper("rss", &stubScraper{items: []models.FeedItem{{
40+
Id: "item-1",
41+
Title: "Café story",
42+
Link: "https://example.com/a",
43+
Content: "caf\xe9 bar", // stray Latin-1 0xe9, invalid UTF-8
44+
PublishedAt: time.Now(),
45+
}}})
46+
47+
// scraping "none" keeps the feed's own content (no re-scrape).
48+
feed := models.Feed{
49+
Id: "feed-1",
50+
URL: "https://example.com/rss",
51+
Type: "rss",
52+
Scraper: datatypes.JSONMap{"scraping": "none"},
53+
}
54+
55+
result, err := m.FetchFeed(feed, nil, nil, false, false, 0)
56+
if err != nil {
57+
t.Fatalf("FetchFeed: %v", err)
58+
}
59+
60+
if result.Stored != 1 {
61+
t.Fatalf("Stored = %d, want 1", result.Stored)
62+
}
63+
if len(result.Errors) != 0 {
64+
t.Fatalf("Errors = %v, want none", result.Errors)
65+
}
66+
if len(result.Warnings) != 1 {
67+
t.Fatalf("Warnings = %v, want 1 sanitize notice", result.Warnings)
68+
}
69+
if len(result.StoredArticleIDs) != 1 {
70+
t.Fatalf("StoredArticleIDs = %v, want 1 id", result.StoredArticleIDs)
71+
}
72+
73+
art, err := db.GetArticle(result.StoredArticleIDs[0])
74+
if err != nil {
75+
t.Fatalf("GetArticle: %v", err)
76+
}
77+
if !utf8.ValidString(art.Content) {
78+
t.Fatalf("stored content is not valid UTF-8: %q", art.Content)
79+
}
80+
if art.Content != "caf bar" {
81+
t.Fatalf("Content = %q, want %q", art.Content, "caf bar")
82+
}
83+
}

cmd/server/internal/manager/inspect.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package manager
22

33
import (
44
"fmt"
5+
"net/url"
56
"strings"
67
"time"
78

@@ -93,6 +94,31 @@ func (m *FeedManager) SuggestSelectors(rawURL, mode string, headers map[string]s
9394
return out, nil
9495
}
9596

97+
// SuggestLinkSelectors scrapes an HTML index page in the given mode and returns
98+
// ranked guesses at the selector scoping its repeating post links, plus the
99+
// date_selector and url_filter implied by the repeating block. Relative hrefs are
100+
// resolved against the index URL. Read-only; backs the html autoconfig pre-phase.
101+
func (m *FeedManager) SuggestLinkSelectors(indexURL, mode string, headers map[string]string, max int) ([]models.LinkListCandidate, error) {
102+
dom, err := m.scrapeArticleDOM(indexURL, mode, headers)
103+
if err != nil {
104+
return nil, err
105+
}
106+
base, _ := url.Parse(indexURL)
107+
ranked := scrapers.SuggestLinkSelectors(dom, base, max)
108+
out := make([]models.LinkListCandidate, len(ranked))
109+
for i, c := range ranked {
110+
out[i] = models.LinkListCandidate{
111+
LinksSelector: c.LinksSelector,
112+
Count: c.Count,
113+
SampleHrefs: c.SampleHrefs,
114+
DateSelector: c.DateSelector,
115+
DateSample: c.DateSample,
116+
URLFilter: c.URLFilter,
117+
}
118+
}
119+
return out, nil
120+
}
121+
96122
// ArticleMainText scrapes a page in the given mode and returns the plain text of its
97123
// most likely article-body element (the top SuggestSelectors candidate). It backs the
98124
// feed-content check, which matches a feed entry against the real page to decide

cmd/server/internal/notification/templates/default/digest.html.tmpl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2532,6 +2532,7 @@ function toggleMoreTags(btn) {
25322532
{ title: 'Welcome to DOWNLINK', body: 'DOWNLINK gives you a fast read on what shipped, broke, or matters across your feeds. Use it to scan and triage. It points you at what to read; it is not a substitute for the articles themselves.' },
25332533
{ sel: '#nav-learn-switch', reveal: true, title: 'Learning mode', body: 'Flip this on for newcomer help: plain-language summaries, click-to-define jargon, and a glossary. Turn it back off once the terms are familiar.' },
25342534
{ sel: '#help-slider', reveal: true, openCard: true, title: 'How much to explain', body: 'Slide between Full and Minimal to set how many terms get explained. Full explains nearly everything; Minimal only the rare stuff.' },
2535+
{ sel: '#nav-help-mini', reveal: true, title: 'Quick level switch', body: 'These three dots in the nav show the current help level. Click them to cycle Full → Partial → Minimal without opening the menu.' },
25352536
{ sel: '#glossary-panel-toggle', reveal: true, title: 'Glossary', body: 'This tab opens a searchable list of every term in the digest, each written in plain words.' }
25362537
];
25372538

0 commit comments

Comments
 (0)