Make the interests table the only source of standing interests (step 3)
The OPML file and the profile's ## Interests section become one-time import inputs; the prompt groups by the stored category and the OPML config key is gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9PrjtUS16PAQve8D4bHgc
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
//! Dashboard: the profile page (`/dashboard/profile`, web plan §11).
|
||||
//!
|
||||
//! Edits `profile.md` with version history, shows what the loader parses out
|
||||
//! of it, the standing OPML interests by theme, the stored system prompt and
|
||||
//! the weekly learned adjustments, and offers the `profile-rebuild` job.
|
||||
//! Edits `profile.md` with version history and shows the stored interests,
|
||||
//! system prompt, and weekly learned adjustments.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -18,6 +17,7 @@ use sqlx::Row;
|
||||
|
||||
use crate::curate::profile::{self, KV_LEARNED_ADJUSTMENTS, ProfileFile, REBUILD_INTERVAL_DAYS};
|
||||
use crate::db::{Db, DbError, KV_TASTE_PROFILE};
|
||||
use crate::interests;
|
||||
use crate::server::AppState;
|
||||
use crate::web::session::{AuthSession, Viewer};
|
||||
use crate::web::{Flash, Html, Page, WebError, format_time, take_flash};
|
||||
@@ -104,8 +104,7 @@ fn read_profile(path: &Path) -> anyhow::Result<Option<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The live preview of what the loader extracts (§11): the passthrough body
|
||||
/// and the `## Interests` lines.
|
||||
/// The live preview of the prose that reaches the prompt.
|
||||
pub fn preview(content: &str) -> ProfileFile {
|
||||
profile::parse_profile_str(content)
|
||||
}
|
||||
@@ -191,7 +190,7 @@ async fn versions(db: &Db, config: &crate::config::Config) -> Result<Vec<Version
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ThemeView {
|
||||
struct CategoryView {
|
||||
name: String,
|
||||
members: String,
|
||||
count: usize,
|
||||
@@ -207,12 +206,10 @@ struct ProfileTemplate {
|
||||
bytes: usize,
|
||||
max_bytes: usize,
|
||||
preview_body: String,
|
||||
preview_interests: Vec<String>,
|
||||
versions: Vec<VersionView>,
|
||||
opml_path: String,
|
||||
opml_count: usize,
|
||||
opml_error: String,
|
||||
themes: Vec<ThemeView>,
|
||||
interest_count: usize,
|
||||
category_count: usize,
|
||||
categories: Vec<CategoryView>,
|
||||
prompt: String,
|
||||
prompt_chars: usize,
|
||||
prompt_version: String,
|
||||
@@ -254,20 +251,17 @@ async fn show(
|
||||
let content = stored.unwrap_or_default();
|
||||
let parsed = preview(&content);
|
||||
|
||||
let (opml_count, opml_error, themes) = match profile::parse_interests(&config.interests_opml) {
|
||||
Ok(interests) => {
|
||||
let themes = profile::group_into_themes(&interests)
|
||||
.into_iter()
|
||||
.map(|(name, members)| ThemeView {
|
||||
name,
|
||||
count: members.len(),
|
||||
members: members.join(", "),
|
||||
})
|
||||
.collect();
|
||||
(interests.len(), String::new(), themes)
|
||||
}
|
||||
Err(error) => (0, format!("{error:#}"), Vec::new()),
|
||||
};
|
||||
let grouped = interests::grouped(db).await.map_err(WebError::Internal)?;
|
||||
let interest_count = grouped.iter().map(|(_, members)| members.len()).sum();
|
||||
let category_count = grouped.len();
|
||||
let categories = grouped
|
||||
.into_iter()
|
||||
.map(|(name, members)| CategoryView {
|
||||
name,
|
||||
count: members.len(),
|
||||
members: members.join(", "),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let prompt = db.kv_get(KV_TASTE_PROFILE).await?.unwrap_or_default();
|
||||
let learned = db.kv_get(KV_LEARNED_ADJUSTMENTS).await?.unwrap_or_default();
|
||||
@@ -297,12 +291,10 @@ async fn show(
|
||||
max_bytes: MAX_PROFILE_BYTES,
|
||||
content,
|
||||
preview_body: parsed.body,
|
||||
preview_interests: parsed.interests,
|
||||
versions: versions(db, &config).await?,
|
||||
opml_path: config.interests_opml.display().to_string(),
|
||||
opml_count,
|
||||
opml_error,
|
||||
themes,
|
||||
interest_count,
|
||||
category_count,
|
||||
categories,
|
||||
prompt_chars: prompt.len(),
|
||||
prompt_verdicts: count_verdict_lines(&prompt),
|
||||
prompt,
|
||||
@@ -516,15 +508,15 @@ mod tests {
|
||||
.unwrap();
|
||||
let config = Config {
|
||||
profile_path: dir.path().join("profile.md"),
|
||||
interests_opml: dir.path().join("interests.opml"),
|
||||
..Config::default()
|
||||
};
|
||||
std::fs::write(&config.profile_path, "# Original\n\nProse.\n").unwrap();
|
||||
std::fs::write(
|
||||
&config.interests_opml,
|
||||
r#"<outline text="Rust"/><outline text="Boston"/>"#,
|
||||
)
|
||||
.unwrap();
|
||||
interests::add(&db, "Rust", Some("Software"), Timestamp::now())
|
||||
.await
|
||||
.unwrap();
|
||||
interests::add(&db, "Boston", Some("Places"), Timestamp::now())
|
||||
.await
|
||||
.unwrap();
|
||||
db.kv_set(
|
||||
KV_TASTE_PROFILE,
|
||||
"system prompt text\n\n## Recent verdicts\n\nLOVED | x\n",
|
||||
@@ -617,7 +609,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_page_shows_editor_preview_interests_prompt_and_rebuild_form() {
|
||||
async fn profile_page_shows_editor_standing_interests_prompt_and_rebuild_form() {
|
||||
let (_dir, _state, app, cookie) = setup().await;
|
||||
let response = get(&app, Some(&cookie)).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
@@ -625,7 +617,8 @@ mod tests {
|
||||
assert!(body.contains("# Original"));
|
||||
assert!(body.contains("Prose."));
|
||||
assert!(body.contains("Rust, Boston") || body.contains("Rust") && body.contains("Boston"));
|
||||
assert!(body.contains("2 interests"));
|
||||
assert!(body.contains("2 standing interests in 2 categories"));
|
||||
assert!(body.contains("Interests page"));
|
||||
assert!(body.contains("system prompt text"));
|
||||
assert!(body.contains("Rank depth higher."));
|
||||
assert!(body.contains("never built"));
|
||||
@@ -666,7 +659,7 @@ mod tests {
|
||||
|
||||
let page = text(get(&app, Some(&cookie)).await).await;
|
||||
assert!(page.contains("Saved; the next run rebuilds the system prompt."));
|
||||
assert!(page.contains("Writerdeck"));
|
||||
assert!(page.contains("section is ignored"));
|
||||
assert!(page.contains("# Original"));
|
||||
assert!(page.contains(">tyler<"));
|
||||
|
||||
|
||||
@@ -246,7 +246,6 @@ const PATH_KEYS: &[&str] = &[
|
||||
"database_path",
|
||||
"out_dir",
|
||||
"profile_path",
|
||||
"interests_opml",
|
||||
"publish.epub_dir",
|
||||
"publish.xtc_dir",
|
||||
"xtc.settings",
|
||||
@@ -295,7 +294,6 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[
|
||||
("database_path", "SQLite file; parent directories are created on demand."),
|
||||
("out_dir", "Where generate writes artifacts before publishing (overridden by --out)."),
|
||||
("profile_path", "Hand-maintained reader profile, loaded every run."),
|
||||
("interests_opml", "Scour interests OPML merged with the profile interests."),
|
||||
("miniflux.base_url", "Miniflux root (no /v1)."),
|
||||
("miniflux.public_url", "Browser-facing Miniflux web UI URL for dashboard links. Defaults to miniflux.base_url."),
|
||||
("miniflux.api_key", "X-Auth-Token for Miniflux. Required; environment only."),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard profile">
|
||||
<header class="page-head"><div>
|
||||
<h1>Profile</h1>
|
||||
<p class="page-desc">The standing taste file the curator reads before every run: what you like, what the OPML declares, and what the editor model has learned from your verdicts.</p>
|
||||
<p class="page-desc">The standing taste file the curator reads before every run, alongside stored interests and what the editor model has learned from your verdicts.</p>
|
||||
</div><div class="page-actions"><a class="btn" href="/dashboard/ratings">Ratings</a><a class="btn" href="/dashboard/settings#curation.feedback">Feedback settings</a></div></header>
|
||||
|
||||
<div class="profile-grid grid gap-6 lg:grid-cols-2">
|
||||
<div class="profile-editor min-w-0">
|
||||
<h3 class="font-mono text-base">profile.md</h3>
|
||||
<p class="meta mt-1 text-xs"><code>{{ path }}</code>{% if !exists %} — <strong class="text-down">missing</strong>; saving creates it{% else %} · {{ bytes }} bytes{% endif %} · limit {{ max_bytes }} bytes. Any <code>## Interests</code> section is parsed one interest per line; everything else goes into the system prompt verbatim.</p>
|
||||
<p class="meta mt-1 text-xs"><code>{{ path }}</code>{% if !exists %} — <strong class="text-down">missing</strong>; saving creates it{% else %} · {{ bytes }} bytes{% endif %} · limit {{ max_bytes }} bytes. Any <code>## Interests</code> section is ignored; everything else goes into the system prompt verbatim.</p>
|
||||
<form method="post" action="/dashboard/profile" class="profile-form mt-3">
|
||||
<textarea name="content" rows="24" spellcheck="true" required class="w-full font-mono text-sm">{{ content }}</textarea>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3"><button class="btn-primary" type="submit">Save</button> <span class="meta text-xs">The next run rebuilds the system prompt from the saved file.</span></div>
|
||||
@@ -19,8 +19,6 @@
|
||||
<p class="meta mt-1 text-xs">A live read of the text on the left, exactly as <code>curate::profile</code> splits it.</p>
|
||||
<h4 class="mt-4 page-eyebrow">Passthrough sections</h4>
|
||||
{% if preview_body.trim().is_empty() %}<p class="meta mt-1 text-sm">Nothing passes through — the file is empty or only has an Interests section.</p>{% else %}<pre class="preview mt-2">{{ preview_body }}</pre>{% endif %}
|
||||
<h4 class="mt-5 page-eyebrow">Extracted <code>## Interests</code> lines</h4>
|
||||
{% if preview_interests.is_empty() %}<p class="meta mt-1 text-sm">None. The prompt uses the OPML interests alone.</p>{% else %}<ul class="mt-2 list-disc space-y-1 pl-5 text-sm marker:text-muted">{% for interest in preview_interests %}<li>{{ interest }}</li>{% endfor %}</ul>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,9 +38,8 @@
|
||||
{% endif %}
|
||||
|
||||
<h2>Standing interests</h2>
|
||||
<p class="meta text-sm"><code>{{ opml_path }}</code> · {{ opml_count }} interests, grouped the way the system prompt lists them. The union of these and the <code>## Interests</code> lines above is what the prompt uses; edit the OPML file to change them.</p>
|
||||
{% if !opml_error.is_empty() %}<p class="error">{{ opml_error }}</p>{% endif %}
|
||||
{% if !themes.is_empty() %}<dl class="kv themes">{% for theme in themes %}<dt class="text-ink">{{ theme.name }} <span class="meta">({{ theme.count }})</span></dt><dd class="text-muted">{{ theme.members }}</dd>{% endfor %}</dl>{% endif %}
|
||||
<p class="meta text-sm">{{ interest_count }} standing interests in {{ category_count }} categories — manage them on the <a href="/dashboard/interests">Interests page</a>.</p>
|
||||
{% if !categories.is_empty() %}<dl class="kv themes">{% for category in categories %}<dt class="text-ink">{{ category.name }} <span class="meta">({{ category.count }})</span></dt><dd class="text-muted">{{ category.members }}</dd>{% endfor %}</dl>{% endif %}
|
||||
|
||||
<h2>Learned adjustments</h2>
|
||||
<p class="meta text-sm">Rebuilt weekly from ratings by the editor model (prompt version {{ prompt_version }}, built {{ prompt_built_at }}, {{ learned_age }}). {% if rebuild_due %}<strong class="text-warn">A rebuild is due</strong> — the next run performs it, or start it now.{% else %}The next scheduled rebuild is at least {{ rebuild_interval_days }} days after the last one; the next run performs it when due.{% endif %}</p>
|
||||
|
||||
Reference in New Issue
Block a user