Token Classification
Transformers
ONNX
Safetensors
English
Japanese
Chinese
bert
anime
filename-parsing
Eval Results (legacy)
Instructions to use ModerRAS/AniFileBERT with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use ModerRAS/AniFileBERT with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("token-classification", model="ModerRAS/AniFileBERT")# pip install -U transformers accelerate # Load model directly from transformers import AutoTokenizer, AutoModelForTokenClassification tokenizer = AutoTokenizer.from_pretrained("ModerRAS/AniFileBERT") model = AutoModelForTokenClassification.from_pretrained("ModerRAS/AniFileBERT", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Add Rust LM Studio title-boundary reviewer
Browse files
tools/rust_dmhy_template_apply/README.md
CHANGED
|
@@ -53,6 +53,22 @@ cargo run --release --manifest-path tools\rust_dmhy_template_apply\Cargo.toml --
|
|
| 53 |
--audit-max-count 50
|
| 54 |
```
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
Optional controls:
|
| 57 |
|
| 58 |
```powershell
|
|
|
|
| 53 |
--audit-max-count 50
|
| 54 |
```
|
| 55 |
|
| 56 |
+
Review ambiguous title-boundary decisions with a local LM Studio model. This is
|
| 57 |
+
an optional final-pass reviewer: it writes a decision cache JSONL and does not
|
| 58 |
+
modify recipes or training data directly.
|
| 59 |
+
|
| 60 |
+
```powershell
|
| 61 |
+
cargo run --release --manifest-path tools\rust_dmhy_template_apply\Cargo.toml -- `
|
| 62 |
+
--review-title-boundaries-lmstudio `
|
| 63 |
+
--recipes reports\dmhy_template_recipes.full_top5000.seed.jsonl `
|
| 64 |
+
--title-boundary-decisions-output reports\dmhy_title_boundary_lmstudio_decisions.jsonl `
|
| 65 |
+
--lmstudio-base-url http://localhost:1234/v1 `
|
| 66 |
+
--lmstudio-model qwen `
|
| 67 |
+
--title-boundary-min-similarity 0.25 `
|
| 68 |
+
--title-boundary-max-similarity 0.85 `
|
| 69 |
+
--limit 50
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
Optional controls:
|
| 73 |
|
| 74 |
```powershell
|
tools/rust_dmhy_template_apply/src/main.rs
CHANGED
|
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
|
| 8 |
use serde_json::{json, Value};
|
| 9 |
use std::collections::{HashMap, HashSet};
|
| 10 |
use std::fs::{self, File};
|
| 11 |
-
use std::io::{BufRead, BufReader, BufWriter, Write};
|
| 12 |
use std::path::PathBuf;
|
| 13 |
use std::sync::atomic::{AtomicUsize, Ordering};
|
| 14 |
|
|
@@ -23,6 +23,8 @@ struct Args {
|
|
| 23 |
verify_generated_output: bool,
|
| 24 |
#[arg(long)]
|
| 25 |
rich_annotations: bool,
|
|
|
|
|
|
|
| 26 |
#[arg(long, default_value = "datasets/AnimeName/dmhy_list.jsonl")]
|
| 27 |
input: PathBuf,
|
| 28 |
#[arg(long, default_value = "reports/dmhy_template_recipes.seed.jsonl")]
|
|
@@ -57,6 +59,23 @@ struct Args {
|
|
| 57 |
audit_output: PathBuf,
|
| 58 |
#[arg(long, default_value = "reports/dmhy_rich_annotations.rust.jsonl")]
|
| 59 |
rich_output: PathBuf,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
#[arg(long, default_value_t = 50)]
|
| 61 |
audit_max_count: u64,
|
| 62 |
#[arg(long)]
|
|
@@ -310,6 +329,9 @@ fn main() -> Result<()> {
|
|
| 310 |
if args.rich_annotations {
|
| 311 |
return run_rich_annotations(&args);
|
| 312 |
}
|
|
|
|
|
|
|
|
|
|
| 313 |
if args.expand != "all" && args.expand != "sample" {
|
| 314 |
bail!("--expand must be all or sample");
|
| 315 |
}
|
|
@@ -1147,6 +1169,285 @@ fn run_rich_annotations(args: &Args) -> Result<()> {
|
|
| 1147 |
Ok(())
|
| 1148 |
}
|
| 1149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1150 |
fn rich_annotation_for(original: &str) -> Value {
|
| 1151 |
let (training_filename, path_trimmed) = training_filename_for(original);
|
| 1152 |
let parts: Vec<&str> = original
|
|
|
|
| 8 |
use serde_json::{json, Value};
|
| 9 |
use std::collections::{HashMap, HashSet};
|
| 10 |
use std::fs::{self, File};
|
| 11 |
+
use std::io::{BufRead, BufReader, BufWriter, Read, Write};
|
| 12 |
use std::path::PathBuf;
|
| 13 |
use std::sync::atomic::{AtomicUsize, Ordering};
|
| 14 |
|
|
|
|
| 23 |
verify_generated_output: bool,
|
| 24 |
#[arg(long)]
|
| 25 |
rich_annotations: bool,
|
| 26 |
+
#[arg(long)]
|
| 27 |
+
review_title_boundaries_lmstudio: bool,
|
| 28 |
#[arg(long, default_value = "datasets/AnimeName/dmhy_list.jsonl")]
|
| 29 |
input: PathBuf,
|
| 30 |
#[arg(long, default_value = "reports/dmhy_template_recipes.seed.jsonl")]
|
|
|
|
| 59 |
audit_output: PathBuf,
|
| 60 |
#[arg(long, default_value = "reports/dmhy_rich_annotations.rust.jsonl")]
|
| 61 |
rich_output: PathBuf,
|
| 62 |
+
#[arg(
|
| 63 |
+
long,
|
| 64 |
+
default_value = "reports/dmhy_title_boundary_lmstudio_decisions.jsonl"
|
| 65 |
+
)]
|
| 66 |
+
title_boundary_decisions_output: PathBuf,
|
| 67 |
+
#[arg(long, default_value = "http://localhost:1234/v1")]
|
| 68 |
+
lmstudio_base_url: String,
|
| 69 |
+
#[arg(long, default_value = "qwen")]
|
| 70 |
+
lmstudio_model: String,
|
| 71 |
+
#[arg(long, default_value_t = 0.25)]
|
| 72 |
+
title_boundary_min_similarity: f64,
|
| 73 |
+
#[arg(long, default_value_t = 0.85)]
|
| 74 |
+
title_boundary_max_similarity: f64,
|
| 75 |
+
#[arg(long, default_value_t = 0.0)]
|
| 76 |
+
lmstudio_temperature: f64,
|
| 77 |
+
#[arg(long, default_value_t = 120)]
|
| 78 |
+
lmstudio_timeout_seconds: u64,
|
| 79 |
#[arg(long, default_value_t = 50)]
|
| 80 |
audit_max_count: u64,
|
| 81 |
#[arg(long)]
|
|
|
|
| 329 |
if args.rich_annotations {
|
| 330 |
return run_rich_annotations(&args);
|
| 331 |
}
|
| 332 |
+
if args.review_title_boundaries_lmstudio {
|
| 333 |
+
return run_review_title_boundaries_lmstudio(&args);
|
| 334 |
+
}
|
| 335 |
if args.expand != "all" && args.expand != "sample" {
|
| 336 |
bail!("--expand must be all or sample");
|
| 337 |
}
|
|
|
|
| 1169 |
Ok(())
|
| 1170 |
}
|
| 1171 |
|
| 1172 |
+
fn run_review_title_boundaries_lmstudio(args: &Args) -> Result<()> {
|
| 1173 |
+
if !(0.0..=1.0).contains(&args.title_boundary_min_similarity)
|
| 1174 |
+
|| !(0.0..=1.0).contains(&args.title_boundary_max_similarity)
|
| 1175 |
+
|| args.title_boundary_min_similarity > args.title_boundary_max_similarity
|
| 1176 |
+
{
|
| 1177 |
+
bail!("title boundary similarity thresholds must satisfy 0 <= min <= max <= 1");
|
| 1178 |
+
}
|
| 1179 |
+
let recipes = load_recipe_values(&args.recipes)?;
|
| 1180 |
+
if recipes.is_empty() {
|
| 1181 |
+
bail!("no recipe rows found in {}", args.recipes.display());
|
| 1182 |
+
}
|
| 1183 |
+
if let Some(parent) = args.title_boundary_decisions_output.parent() {
|
| 1184 |
+
fs::create_dir_all(parent)?;
|
| 1185 |
+
}
|
| 1186 |
+
let mut writer = BufWriter::new(File::create(&args.title_boundary_decisions_output)?);
|
| 1187 |
+
let mut reviewed = 0usize;
|
| 1188 |
+
let mut skipped = 0usize;
|
| 1189 |
+
let endpoint = format!(
|
| 1190 |
+
"{}/chat/completions",
|
| 1191 |
+
args.lmstudio_base_url.trim_end_matches('/')
|
| 1192 |
+
);
|
| 1193 |
+
|
| 1194 |
+
for recipe in recipes {
|
| 1195 |
+
let template_id = recipe
|
| 1196 |
+
.get("template_id")
|
| 1197 |
+
.and_then(Value::as_str)
|
| 1198 |
+
.unwrap_or("unknown");
|
| 1199 |
+
let decisions = recipe
|
| 1200 |
+
.get("title_boundary_decisions")
|
| 1201 |
+
.and_then(Value::as_array)
|
| 1202 |
+
.cloned()
|
| 1203 |
+
.unwrap_or_default();
|
| 1204 |
+
for decision in decisions {
|
| 1205 |
+
let similarity = decision
|
| 1206 |
+
.get("similarity")
|
| 1207 |
+
.and_then(Value::as_f64)
|
| 1208 |
+
.unwrap_or(0.0);
|
| 1209 |
+
let heuristic_decision = decision
|
| 1210 |
+
.get("decision")
|
| 1211 |
+
.and_then(Value::as_str)
|
| 1212 |
+
.unwrap_or("unknown");
|
| 1213 |
+
if heuristic_decision.contains("path_boundary")
|
| 1214 |
+
|| heuristic_decision.contains("structural_boundary")
|
| 1215 |
+
|| similarity < args.title_boundary_min_similarity
|
| 1216 |
+
|| similarity > args.title_boundary_max_similarity
|
| 1217 |
+
{
|
| 1218 |
+
skipped += 1;
|
| 1219 |
+
continue;
|
| 1220 |
+
}
|
| 1221 |
+
if let Some(limit) = args.limit {
|
| 1222 |
+
if reviewed >= limit {
|
| 1223 |
+
break;
|
| 1224 |
+
}
|
| 1225 |
+
}
|
| 1226 |
+
let prompt = title_boundary_review_prompt(&recipe, &decision);
|
| 1227 |
+
let response = call_lmstudio_title_boundary(
|
| 1228 |
+
&endpoint,
|
| 1229 |
+
&args.lmstudio_model,
|
| 1230 |
+
args.lmstudio_temperature,
|
| 1231 |
+
args.lmstudio_timeout_seconds,
|
| 1232 |
+
&prompt,
|
| 1233 |
+
)
|
| 1234 |
+
.with_context(|| {
|
| 1235 |
+
format!(
|
| 1236 |
+
"LM Studio title-boundary review failed for template {template_id} via {endpoint}"
|
| 1237 |
+
)
|
| 1238 |
+
})?;
|
| 1239 |
+
let content = response
|
| 1240 |
+
.get("choices")
|
| 1241 |
+
.and_then(Value::as_array)
|
| 1242 |
+
.and_then(|choices| choices.first())
|
| 1243 |
+
.and_then(|choice| choice.get("message"))
|
| 1244 |
+
.and_then(|message| message.get("content"))
|
| 1245 |
+
.and_then(Value::as_str)
|
| 1246 |
+
.unwrap_or("");
|
| 1247 |
+
let parsed = parse_lmstudio_json_object(content).unwrap_or_else(|| {
|
| 1248 |
+
json!({
|
| 1249 |
+
"decision": "skip",
|
| 1250 |
+
"kind": "parse_error",
|
| 1251 |
+
"confidence": 0.0,
|
| 1252 |
+
"reason": content,
|
| 1253 |
+
})
|
| 1254 |
+
});
|
| 1255 |
+
let output = json!({
|
| 1256 |
+
"template_id": recipe.get("template_id").cloned().unwrap_or(Value::Null),
|
| 1257 |
+
"template": recipe.get("template").cloned().unwrap_or(Value::Null),
|
| 1258 |
+
"left_role_index": decision.get("left_role_index").cloned().unwrap_or(Value::Null),
|
| 1259 |
+
"right_role_index": decision.get("right_role_index").cloned().unwrap_or(Value::Null),
|
| 1260 |
+
"similarity": similarity,
|
| 1261 |
+
"heuristic_decision": heuristic_decision,
|
| 1262 |
+
"model_decision": parsed.get("decision").cloned().unwrap_or(Value::Null),
|
| 1263 |
+
"model_kind": parsed.get("kind").cloned().unwrap_or(Value::Null),
|
| 1264 |
+
"confidence": parsed.get("confidence").cloned().unwrap_or(Value::Null),
|
| 1265 |
+
"reason": parsed.get("reason").cloned().unwrap_or(Value::Null),
|
| 1266 |
+
"source": "lmstudio",
|
| 1267 |
+
"model": args.lmstudio_model,
|
| 1268 |
+
"reviewed_at": Utc::now().to_rfc3339(),
|
| 1269 |
+
});
|
| 1270 |
+
serde_json::to_writer(&mut writer, &output)?;
|
| 1271 |
+
writer.write_all(b"\n")?;
|
| 1272 |
+
reviewed += 1;
|
| 1273 |
+
}
|
| 1274 |
+
}
|
| 1275 |
+
writer.flush()?;
|
| 1276 |
+
let summary = json!({
|
| 1277 |
+
"recipes": args.recipes.to_string_lossy(),
|
| 1278 |
+
"output": args.title_boundary_decisions_output.to_string_lossy(),
|
| 1279 |
+
"reviewed": reviewed,
|
| 1280 |
+
"skipped": skipped,
|
| 1281 |
+
"model": args.lmstudio_model,
|
| 1282 |
+
"base_url": args.lmstudio_base_url,
|
| 1283 |
+
"similarity_range": [args.title_boundary_min_similarity, args.title_boundary_max_similarity],
|
| 1284 |
+
"generated_at": Utc::now().to_rfc3339(),
|
| 1285 |
+
});
|
| 1286 |
+
println!("{}", serde_json::to_string_pretty(&summary)?);
|
| 1287 |
+
Ok(())
|
| 1288 |
+
}
|
| 1289 |
+
|
| 1290 |
+
fn load_recipe_values(path: &PathBuf) -> Result<Vec<Value>> {
|
| 1291 |
+
let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
|
| 1292 |
+
let reader = BufReader::new(file);
|
| 1293 |
+
let mut rows = Vec::new();
|
| 1294 |
+
for (line_number, line) in reader.lines().enumerate() {
|
| 1295 |
+
let line = line.with_context(|| format!("failed to read {}", path.display()))?;
|
| 1296 |
+
if line.trim().is_empty() {
|
| 1297 |
+
continue;
|
| 1298 |
+
}
|
| 1299 |
+
let value: Value = serde_json::from_str(&line)
|
| 1300 |
+
.with_context(|| format!("invalid JSON at {}:{}", path.display(), line_number + 1))?;
|
| 1301 |
+
rows.push(value);
|
| 1302 |
+
}
|
| 1303 |
+
Ok(rows)
|
| 1304 |
+
}
|
| 1305 |
+
|
| 1306 |
+
fn title_boundary_review_prompt(recipe: &Value, decision: &Value) -> String {
|
| 1307 |
+
let left = decision
|
| 1308 |
+
.get("left_role_index")
|
| 1309 |
+
.and_then(Value::as_u64)
|
| 1310 |
+
.unwrap_or(0) as usize;
|
| 1311 |
+
let right = decision
|
| 1312 |
+
.get("right_role_index")
|
| 1313 |
+
.and_then(Value::as_u64)
|
| 1314 |
+
.unwrap_or(0) as usize;
|
| 1315 |
+
let position_literals = recipe
|
| 1316 |
+
.get("position_top_literals")
|
| 1317 |
+
.and_then(Value::as_array);
|
| 1318 |
+
let left_literals = position_literals
|
| 1319 |
+
.and_then(|items| items.get(left))
|
| 1320 |
+
.cloned()
|
| 1321 |
+
.unwrap_or_else(|| json!([]));
|
| 1322 |
+
let right_literals = position_literals
|
| 1323 |
+
.and_then(|items| items.get(right))
|
| 1324 |
+
.cloned()
|
| 1325 |
+
.unwrap_or_else(|| json!([]));
|
| 1326 |
+
let examples = recipe
|
| 1327 |
+
.get("examples")
|
| 1328 |
+
.and_then(Value::as_array)
|
| 1329 |
+
.map(|items| items.iter().take(8).cloned().collect::<Vec<_>>())
|
| 1330 |
+
.unwrap_or_default();
|
| 1331 |
+
let payload = json!({
|
| 1332 |
+
"task": "Classify whether two adjacent TITLE slots in an anime filename template should be one logical title span or kept separate.",
|
| 1333 |
+
"allowed_decisions": {
|
| 1334 |
+
"merge": "same title continuation; use one logical title span",
|
| 1335 |
+
"keep": "separate title slots such as duplicate/path title, alias, multilingual title, or distinct fields",
|
| 1336 |
+
"skip": "uncertain or unsafe for synthetic replacement"
|
| 1337 |
+
},
|
| 1338 |
+
"template_id": recipe.get("template_id"),
|
| 1339 |
+
"template": recipe.get("template"),
|
| 1340 |
+
"roles": recipe.get("roles"),
|
| 1341 |
+
"boundary": decision,
|
| 1342 |
+
"left_position_top_literals": left_literals,
|
| 1343 |
+
"right_position_top_literals": right_literals,
|
| 1344 |
+
"examples": examples,
|
| 1345 |
+
"output_schema": {
|
| 1346 |
+
"decision": "merge|keep|skip",
|
| 1347 |
+
"kind": "same_title_continuation|duplicate_title|multilingual_alias|path_duplicate|distinct_fields|uncertain",
|
| 1348 |
+
"confidence": "number from 0 to 1",
|
| 1349 |
+
"reason": "short explanation"
|
| 1350 |
+
}
|
| 1351 |
+
});
|
| 1352 |
+
format!(
|
| 1353 |
+
"Return only one compact JSON object matching output_schema. No markdown.\n{}",
|
| 1354 |
+
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
|
| 1355 |
+
)
|
| 1356 |
+
}
|
| 1357 |
+
|
| 1358 |
+
fn call_lmstudio_title_boundary(
|
| 1359 |
+
endpoint: &str,
|
| 1360 |
+
model: &str,
|
| 1361 |
+
temperature: f64,
|
| 1362 |
+
timeout_seconds: u64,
|
| 1363 |
+
prompt: &str,
|
| 1364 |
+
) -> Result<Value> {
|
| 1365 |
+
let body = json!({
|
| 1366 |
+
"model": model,
|
| 1367 |
+
"temperature": temperature,
|
| 1368 |
+
"messages": [
|
| 1369 |
+
{
|
| 1370 |
+
"role": "system",
|
| 1371 |
+
"content": "You are a strict data-labeling reviewer for anime release filename templates. Reply with JSON only."
|
| 1372 |
+
},
|
| 1373 |
+
{"role": "user", "content": prompt}
|
| 1374 |
+
]
|
| 1375 |
+
});
|
| 1376 |
+
post_json_http(endpoint, &body, timeout_seconds)
|
| 1377 |
+
}
|
| 1378 |
+
|
| 1379 |
+
fn post_json_http(endpoint: &str, body: &Value, timeout_seconds: u64) -> Result<Value> {
|
| 1380 |
+
let (host, port, path) = parse_http_endpoint(endpoint)?;
|
| 1381 |
+
let host_port = format!("{host}:{port}");
|
| 1382 |
+
let mut stream = std::net::TcpStream::connect((&*host, port))
|
| 1383 |
+
.with_context(|| format!("failed to connect to {host_port}"))?;
|
| 1384 |
+
let timeout = std::time::Duration::from_secs(timeout_seconds);
|
| 1385 |
+
stream.set_read_timeout(Some(timeout))?;
|
| 1386 |
+
stream.set_write_timeout(Some(timeout))?;
|
| 1387 |
+
let body_text = serde_json::to_string(body)?;
|
| 1388 |
+
let request = format!(
|
| 1389 |
+
"POST {path} HTTP/1.1\r\nHost: {host_port}\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
| 1390 |
+
body_text.len(),
|
| 1391 |
+
body_text
|
| 1392 |
+
);
|
| 1393 |
+
stream.write_all(request.as_bytes())?;
|
| 1394 |
+
let mut response = String::new();
|
| 1395 |
+
stream.read_to_string(&mut response)?;
|
| 1396 |
+
let (head, response_body) = response
|
| 1397 |
+
.split_once("\r\n\r\n")
|
| 1398 |
+
.ok_or_else(|| anyhow::anyhow!("invalid HTTP response from {endpoint}"))?;
|
| 1399 |
+
let status_line = head.lines().next().unwrap_or_default();
|
| 1400 |
+
let status_code = status_line
|
| 1401 |
+
.split_whitespace()
|
| 1402 |
+
.nth(1)
|
| 1403 |
+
.and_then(|value| value.parse::<u16>().ok())
|
| 1404 |
+
.unwrap_or(0);
|
| 1405 |
+
if !(200..300).contains(&status_code) {
|
| 1406 |
+
bail!("LM Studio returned HTTP {status_code}: {response_body}");
|
| 1407 |
+
}
|
| 1408 |
+
serde_json::from_str(response_body).with_context(|| "invalid JSON response from LM Studio")
|
| 1409 |
+
}
|
| 1410 |
+
|
| 1411 |
+
fn parse_http_endpoint(endpoint: &str) -> Result<(String, u16, String)> {
|
| 1412 |
+
let rest = endpoint.strip_prefix("http://").ok_or_else(|| {
|
| 1413 |
+
anyhow::anyhow!("only http:// LM Studio endpoints are supported: {endpoint}")
|
| 1414 |
+
})?;
|
| 1415 |
+
let (authority, path) = match rest.split_once('/') {
|
| 1416 |
+
Some((authority, path)) => (authority, format!("/{path}")),
|
| 1417 |
+
None => (rest, "/".to_string()),
|
| 1418 |
+
};
|
| 1419 |
+
if authority.is_empty() {
|
| 1420 |
+
bail!("missing host in endpoint {endpoint}");
|
| 1421 |
+
}
|
| 1422 |
+
let (host, port) = match authority.rsplit_once(':') {
|
| 1423 |
+
Some((host, port)) if !host.is_empty() => {
|
| 1424 |
+
let port = port
|
| 1425 |
+
.parse::<u16>()
|
| 1426 |
+
.with_context(|| format!("invalid port in {endpoint}"))?;
|
| 1427 |
+
(host.to_string(), port)
|
| 1428 |
+
}
|
| 1429 |
+
_ => (authority.to_string(), 80),
|
| 1430 |
+
};
|
| 1431 |
+
Ok((host, port, path))
|
| 1432 |
+
}
|
| 1433 |
+
|
| 1434 |
+
fn parse_lmstudio_json_object(content: &str) -> Option<Value> {
|
| 1435 |
+
if let Ok(value) = serde_json::from_str::<Value>(content.trim()) {
|
| 1436 |
+
return if value.is_object() { Some(value) } else { None };
|
| 1437 |
+
}
|
| 1438 |
+
let start = content.find('{')?;
|
| 1439 |
+
let end = content.rfind('}')?;
|
| 1440 |
+
if end <= start {
|
| 1441 |
+
return None;
|
| 1442 |
+
}
|
| 1443 |
+
let value = serde_json::from_str::<Value>(&content[start..=end]).ok()?;
|
| 1444 |
+
if value.is_object() {
|
| 1445 |
+
Some(value)
|
| 1446 |
+
} else {
|
| 1447 |
+
None
|
| 1448 |
+
}
|
| 1449 |
+
}
|
| 1450 |
+
|
| 1451 |
fn rich_annotation_for(original: &str) -> Value {
|
| 1452 |
let (training_filename, path_trimmed) = training_filename_for(original);
|
| 1453 |
let parts: Vec<&str> = original
|