ModerRAS commited on
Commit
b689df8
·
1 Parent(s): 7509455

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