feat: more completion for M4
This commit is contained in:
@@ -302,7 +302,7 @@ pub fn run_one_shot(
|
||||
let settings = load_ai_settings(conn, offline_mode)?;
|
||||
let endpoint = active_endpoint(conn, offline_mode)?;
|
||||
let model = select_model(&settings, &endpoint, &request.operation)?;
|
||||
let prompt = build_one_shot_prompt(request)?;
|
||||
let user_content = build_one_shot_user_content(request)?;
|
||||
let schema = response_schema(&request.operation);
|
||||
let payload = json!({
|
||||
"model": model,
|
||||
@@ -313,7 +313,7 @@ pub fn run_one_shot(
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
"content": user_content,
|
||||
}
|
||||
],
|
||||
"response_format": {
|
||||
@@ -412,37 +412,63 @@ fn build_system_prompt(base_prompt: &str, operation: &OneShotOperation) -> Strin
|
||||
}
|
||||
}
|
||||
|
||||
fn build_one_shot_prompt(request: &OneShotRequest) -> EngineResult<String> {
|
||||
fn build_one_shot_user_content(request: &OneShotRequest) -> EngineResult<Value> {
|
||||
match &request.operation {
|
||||
OneShotOperation::AnalyzeTaxonomy => Ok(format!(
|
||||
"Suggest tags and categories for this post: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
).into()),
|
||||
OneShotOperation::AnalyzePost => Ok(format!(
|
||||
"Analyze this post and suggest title, excerpt, and slug: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
).into()),
|
||||
OneShotOperation::DetectLanguage => Ok(format!(
|
||||
"Detect the language of this text: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
).into()),
|
||||
OneShotOperation::TranslatePost { target_language } => Ok(format!(
|
||||
"Translate this post to {}: {}",
|
||||
target_language,
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
OneShotOperation::AnalyzeImage => Ok(format!(
|
||||
"Analyze this image metadata and return title, alt, and caption suggestions: {}",
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
).into()),
|
||||
OneShotOperation::AnalyzeImage => build_image_analysis_user_content(&request.content),
|
||||
OneShotOperation::TranslateMedia { target_language } => Ok(format!(
|
||||
"Translate this media metadata to {}: {}",
|
||||
target_language,
|
||||
serde_json::to_string(&request.content)?
|
||||
)),
|
||||
).into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_image_analysis_user_content(content: &Value) -> EngineResult<Value> {
|
||||
let image_data_url = content
|
||||
.get("image_data_url")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| EngineError::Validation("image analysis requires image data".to_string()))?;
|
||||
|
||||
let mut metadata = content.clone();
|
||||
if let Some(object) = metadata.as_object_mut() {
|
||||
object.remove("image_data_url");
|
||||
}
|
||||
|
||||
Ok(json!([
|
||||
{
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"Analyze this image and return title, alt, and caption suggestions. Metadata: {}",
|
||||
serde_json::to_string(&metadata)?
|
||||
)
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": image_data_url
|
||||
}
|
||||
}
|
||||
]))
|
||||
}
|
||||
|
||||
fn response_schema(operation: &OneShotOperation) -> (&'static str, Value) {
|
||||
match operation {
|
||||
OneShotOperation::AnalyzeTaxonomy => (
|
||||
@@ -731,6 +757,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_analysis_user_content_is_multimodal() {
|
||||
let content = build_image_analysis_user_content(&json!({
|
||||
"title": "Existing title",
|
||||
"alt": "Existing alt",
|
||||
"image_data_url": "data:image/jpeg;base64,abc123"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let parts = content.as_array().unwrap();
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert_eq!(parts[0]["type"], "text");
|
||||
assert!(parts[0]["text"].as_str().unwrap().contains("Existing title"));
|
||||
assert_eq!(parts[1]["type"], "image_url");
|
||||
assert_eq!(parts[1]["image_url"]["url"], "data:image/jpeg;base64,abc123");
|
||||
}
|
||||
|
||||
fn spawn_test_server(handler: impl Fn(String) -> String + Send + 'static) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::db::queries;
|
||||
use crate::engine::site_assets::write_bundled_site_assets;
|
||||
use crate::engine::validate_site::SiteValidationReport;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Post, ProjectMetadata};
|
||||
use crate::model::{CategorySettings, Post, ProjectMetadata};
|
||||
use crate::render::{
|
||||
GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path,
|
||||
build_site_render_artifacts, render_markdown_to_html, write_generated_bytes,
|
||||
@@ -49,11 +49,14 @@ pub fn generate_starter_site(
|
||||
_language: &str,
|
||||
) -> EngineResult<GenerationReport> {
|
||||
let mut report = GenerationReport::default();
|
||||
let data_dir = project_data_dir(output_dir);
|
||||
let category_settings = load_category_settings(&data_dir);
|
||||
let list_posts = filter_posts_for_lists(posts, &category_settings);
|
||||
let input_posts = posts
|
||||
.iter()
|
||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let artifacts = build_site_render_artifacts(conn, output_dir.parent().unwrap_or(output_dir), project_id, metadata, &input_posts)
|
||||
let artifacts = build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||
|
||||
for page in &artifacts.pages {
|
||||
@@ -67,12 +70,12 @@ pub fn generate_starter_site(
|
||||
output_dir,
|
||||
project_id,
|
||||
"calendar.json",
|
||||
&build_calendar_json(&posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
||||
&build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
||||
&mut report,
|
||||
)?;
|
||||
|
||||
for render_language in render_languages(metadata) {
|
||||
let localized_posts = localized_sources(conn, output_dir.parent().unwrap_or(output_dir), posts, &render_language, metadata)?;
|
||||
let localized_posts = localized_sources(conn, &data_dir, &list_posts, &render_language, metadata)?;
|
||||
let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -142,6 +145,8 @@ pub fn apply_validation_sections(
|
||||
|
||||
let section_set = sections.iter().copied().collect::<HashSet<_>>();
|
||||
let data_dir = project_data_dir(output_dir);
|
||||
let category_settings = load_category_settings(&data_dir);
|
||||
let list_posts = filter_posts_for_lists(posts, &category_settings);
|
||||
let input_posts = posts
|
||||
.iter()
|
||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
||||
@@ -170,7 +175,7 @@ pub fn apply_validation_sections(
|
||||
output_dir,
|
||||
project_id,
|
||||
"calendar.json",
|
||||
&build_calendar_json(&posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
||||
&build_calendar_json(&list_posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
|
||||
&mut report,
|
||||
)?;
|
||||
|
||||
@@ -178,7 +183,7 @@ pub fn apply_validation_sections(
|
||||
let localized_posts = localized_sources(
|
||||
conn,
|
||||
&data_dir,
|
||||
posts,
|
||||
&list_posts,
|
||||
&render_language,
|
||||
metadata,
|
||||
)?;
|
||||
@@ -511,6 +516,27 @@ fn localized_sources(
|
||||
Ok(localized)
|
||||
}
|
||||
|
||||
fn load_category_settings(data_dir: &Path) -> HashMap<String, CategorySettings> {
|
||||
crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn filter_posts_for_lists(
|
||||
posts: &[PublishedPostSource],
|
||||
category_settings: &HashMap<String, CategorySettings>,
|
||||
) -> Vec<PublishedPostSource> {
|
||||
posts.iter()
|
||||
.filter(|source| {
|
||||
!source.post.categories.iter().any(|category| {
|
||||
category_settings
|
||||
.get(category)
|
||||
.map(|settings| !settings.render_in_lists)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String {
|
||||
let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/');
|
||||
let last_build = posts
|
||||
|
||||
@@ -607,6 +607,38 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_server_serves_media_and_asset_files() {
|
||||
let _guard = preview_port_guard().lock().unwrap();
|
||||
let (dir, _db) = setup_preview_fixture();
|
||||
std::fs::create_dir_all(dir.path().join("assets")).unwrap();
|
||||
std::fs::write(dir.path().join("media/ok.txt"), "ok").unwrap();
|
||||
std::fs::write(dir.path().join("assets/site.css"), "body { color: red; }").unwrap();
|
||||
|
||||
let server = start_preview_server(
|
||||
dir.path().join("bds.db"),
|
||||
dir.path().to_path_buf(),
|
||||
"project-1".into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let media = client
|
||||
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/ok.txt"))
|
||||
.send()
|
||||
.unwrap();
|
||||
let asset = client
|
||||
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/assets/site.css"))
|
||||
.send()
|
||||
.unwrap();
|
||||
let media_body = media.text().unwrap();
|
||||
let asset_body = asset.text().unwrap();
|
||||
server.stop().unwrap();
|
||||
|
||||
assert_eq!(media_body, "ok");
|
||||
assert!(asset_body.contains("color: red"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_server_serves_style_preview() {
|
||||
let _guard = preview_port_guard().lock().unwrap();
|
||||
@@ -659,4 +691,73 @@ mod tests {
|
||||
assert!(body.contains("data-theme=\"nightfall\""));
|
||||
assert!(body.contains("data-mode=\"dark\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_respects_category_list_visibility_and_show_title_rules() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||
crate::engine::meta::write_category_meta_json(
|
||||
dir.path(),
|
||||
&std::collections::HashMap::from([
|
||||
(
|
||||
"hidden".to_string(),
|
||||
crate::model::CategorySettings {
|
||||
render_in_lists: false,
|
||||
show_title: true,
|
||||
post_template_slug: None,
|
||||
list_template_slug: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"featured".to_string(),
|
||||
crate::model::CategorySettings {
|
||||
render_in_lists: true,
|
||||
show_title: false,
|
||||
post_template_slug: None,
|
||||
list_template_slug: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let mut hidden = make_post();
|
||||
hidden.post.title = "Hidden Post".into();
|
||||
hidden.post.slug = "hidden-post".into();
|
||||
hidden.post.categories = vec!["hidden".into()];
|
||||
hidden.body_markdown = "Hidden body".into();
|
||||
|
||||
let mut featured = make_post();
|
||||
featured.post.title = "Featured Post".into();
|
||||
featured.post.slug = "featured-post".into();
|
||||
featured.post.categories = vec!["featured".into()];
|
||||
featured.body_markdown = "Featured body".into();
|
||||
|
||||
let hidden_response = build_preview_response(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"project-1",
|
||||
&make_metadata(),
|
||||
&[(hidden.post.clone(), hidden.body_markdown.clone()), (featured.post.clone(), featured.body_markdown.clone())],
|
||||
"/category/hidden",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(hidden_response.status_code, 404);
|
||||
|
||||
let featured_response = build_preview_response(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"project-1",
|
||||
&make_metadata(),
|
||||
&[(hidden.post, hidden.body_markdown), (featured.post, featured.body_markdown)],
|
||||
"/category/featured",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(featured_response.status_code, 200);
|
||||
assert!(featured_response.html.contains("Featured body"));
|
||||
assert!(!featured_response.html.contains("<h2 class=\"post-title\""));
|
||||
assert!(!featured_response.html.contains("Featured Post"));
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[
|
||||
BundledSiteAsset { relative_path: "assets/tag-cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/tag-cloud.js") },
|
||||
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.css") },
|
||||
BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.js") },
|
||||
BundledSiteAsset { relative_path: "images/close.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png") },
|
||||
BundledSiteAsset { relative_path: "images/loading.gif", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/loading.gif") },
|
||||
BundledSiteAsset { relative_path: "images/next.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/next.png") },
|
||||
BundledSiteAsset { relative_path: "images/prev.png", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/images/prev.png") },
|
||||
];
|
||||
|
||||
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
|
||||
|
||||
Reference in New Issue
Block a user