581 lines
21 KiB
Python
581 lines
21 KiB
Python
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import statistics
|
|
from collections import Counter
|
|
from urllib import request, error
|
|
|
|
|
|
FOREIGN_INSERTIONS = [
|
|
"pero en espanol dice que no retrocedas",
|
|
"y tambien dijo que Dios responde",
|
|
"mas ele continuou em fe",
|
|
"et il a dit de rester ferme",
|
|
]
|
|
|
|
|
|
def parse_distribute_debug_log(path):
|
|
entries = []
|
|
current = {"final": None, "en_bridge": None, "llm_paragraph": None}
|
|
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
for raw_line in handle:
|
|
line = raw_line.rstrip("\n")
|
|
if "[Final]:" in line:
|
|
current["final"] = line.split("[Final]:", 1)[1].strip()
|
|
elif "[EN-BRIDGE]:" in line:
|
|
current["en_bridge"] = line.split("[EN-BRIDGE]:", 1)[1].strip()
|
|
elif "[LLM-PARAGRAPH]:" in line:
|
|
current["llm_paragraph"] = line.split("[LLM-PARAGRAPH]:", 1)[1].strip()
|
|
entries.append(current)
|
|
current = {"final": None, "en_bridge": None, "llm_paragraph": None}
|
|
elif not line.strip():
|
|
continue
|
|
|
|
return entries
|
|
|
|
|
|
def normalize_paragraph(text):
|
|
return (text or "").strip().strip('"').strip()
|
|
|
|
|
|
def reconstruct_samples(path):
|
|
samples = []
|
|
rows = parse_distribute_debug_log(path)
|
|
previous_llm = ""
|
|
pending_bridges = []
|
|
recent_raw_windows = []
|
|
|
|
for row in rows:
|
|
if row.get("en_bridge"):
|
|
pending_bridges.append(row["en_bridge"])
|
|
if not row.get("llm_paragraph"):
|
|
continue
|
|
|
|
llm_output = normalize_paragraph(row["llm_paragraph"])
|
|
new_raw = " ".join(pending_bridges).strip()
|
|
prev_raw = recent_raw_windows[-1] if recent_raw_windows else ""
|
|
|
|
samples.append(
|
|
{
|
|
"previous_refined": previous_llm,
|
|
"previous_raw": prev_raw,
|
|
"new_raw": new_raw,
|
|
"observed_output": llm_output,
|
|
}
|
|
)
|
|
|
|
previous_llm = llm_output
|
|
recent_raw_windows.append(new_raw)
|
|
pending_bridges = []
|
|
|
|
return samples
|
|
|
|
|
|
def inject_multilingual_noise(text, sample_index, slot_name):
|
|
if not text:
|
|
return text
|
|
inserts = [
|
|
FOREIGN_INSERTIONS[(sample_index + 0) % len(FOREIGN_INSERTIONS)],
|
|
FOREIGN_INSERTIONS[(sample_index + 1) % len(FOREIGN_INSERTIONS)],
|
|
]
|
|
sentences = [part.strip() for part in re.split(r"(?<=[.!?])\s+", text.strip()) if part.strip()]
|
|
if not sentences:
|
|
return text
|
|
if len(sentences) == 1:
|
|
return f'{sentences[0]} {inserts[0]}.'
|
|
head = sentences[0]
|
|
tail = " ".join(sentences[1:])
|
|
if slot_name == "previous_raw":
|
|
return f"{head} {inserts[0]}. {tail}"
|
|
return f"{head} {tail} {inserts[1]}."
|
|
|
|
|
|
def maybe_augment_samples(samples, mode):
|
|
if mode != "multilingual_noise":
|
|
return samples
|
|
|
|
augmented = []
|
|
for idx, sample in enumerate(samples):
|
|
updated = dict(sample)
|
|
updated["previous_raw"] = inject_multilingual_noise(sample["previous_raw"], idx, "previous_raw")
|
|
updated["new_raw"] = inject_multilingual_noise(sample["new_raw"], idx, "new_raw")
|
|
augmented.append(updated)
|
|
return augmented
|
|
|
|
|
|
def build_variants(sample, variant_filter=None):
|
|
prev_refined = sample["previous_refined"]
|
|
prev_raw = sample["previous_raw"]
|
|
new_raw = sample["new_raw"]
|
|
|
|
variants = []
|
|
|
|
system_a = """You are a live transcript editor.
|
|
|
|
Task:
|
|
Refine live transcription stream into clean, professional paragraphs.
|
|
|
|
Rules:
|
|
1. Integrate new segments into the flow of the previous working context.
|
|
2. Remove redundant repetitions and translator echoes.
|
|
3. Use a double newline (\\n\\n) to start a new paragraph when a topic changes or the current one is complete.
|
|
4. Output only in English.
|
|
5. Return only the refined, consolidated text with no explanation."""
|
|
user_a = f"""Refine the following live transcription stream.
|
|
|
|
[PREVIOUS WORKING CONTEXT]
|
|
"{prev_refined}"
|
|
|
|
[NEW SOURCE TEXT]
|
|
"{new_raw}"
|
|
"""
|
|
variants.append(("current_refined_plus_new", system_a, user_a))
|
|
|
|
system_b = """You are a live transcript editor for noisy speech recognition output.
|
|
|
|
Goal:
|
|
Produce a clean English paragraph update grounded in the source text.
|
|
|
|
Grounding rules:
|
|
1. Treat PREVIOUS REFINED CONTEXT as style and continuity help, not as unquestionable truth.
|
|
2. Use PREVIOUS SOURCE TEXT and NEW SOURCE TEXT as the factual anchor.
|
|
3. Do not add details that are not supported by the source text.
|
|
4. Preserve uncertainty when the source is unclear instead of inventing a cleaner claim.
|
|
5. Remove obvious repetitions and translator echoes.
|
|
6. Start a new paragraph with a double newline only when there is a clear topic shift.
|
|
7. Return only the refined English text with no explanation."""
|
|
user_b = f"""Update the transcript using the source text below.
|
|
|
|
[PREVIOUS REFINED CONTEXT]
|
|
"{prev_refined}"
|
|
|
|
[PREVIOUS SOURCE TEXT]
|
|
"{prev_raw}"
|
|
|
|
[NEW SOURCE TEXT]
|
|
"{new_raw}"
|
|
"""
|
|
variants.append(("refined_plus_prev_raw_plus_new_raw", system_b, user_b))
|
|
|
|
system_c = """You are a careful transcript editor.
|
|
|
|
Goal:
|
|
Produce clean English paragraphs directly from noisy source text.
|
|
|
|
Rules:
|
|
1. Use only the supplied source text as evidence.
|
|
2. Do not infer missing facts.
|
|
3. Keep wording conservative when the source is noisy.
|
|
4. Remove repeated fragments and obvious translation artifacts.
|
|
5. Use a double newline only for a clear paragraph break.
|
|
6. Return only the refined English text."""
|
|
user_c = f"""Refine this transcript source.
|
|
|
|
[RECENT SOURCE TEXT]
|
|
"{prev_raw}"
|
|
|
|
[NEW SOURCE TEXT]
|
|
"{new_raw}"
|
|
"""
|
|
variants.append(("prev_raw_plus_new_raw_only", system_c, user_c))
|
|
|
|
system_d = """You are a live transcript editor for sermon audio translated into English.
|
|
|
|
Goal:
|
|
Create the most faithful readable English paragraph you can from imperfect source text.
|
|
|
|
Priority order:
|
|
1. Faithfulness to source text.
|
|
2. Remove duplicated phrases, stutters, and translation echoes.
|
|
3. Maintain continuity with the previous refined context only when it matches the source text.
|
|
4. Prefer slight awkwardness over hallucination.
|
|
5. If a phrase is unclear, keep it modestly literal instead of guessing.
|
|
6. Use a double newline only for a real topic change.
|
|
7. Return only the revised English transcript.
|
|
|
|
Hard constraints:
|
|
- Do not add people, events, or meanings absent from the source text.
|
|
- Do not turn rhetorical questions into factual claims unless the source clearly does that.
|
|
- Do not overwrite NEW SOURCE TEXT with PREVIOUS REFINED CONTEXT."""
|
|
user_d = f"""Revise the transcript update.
|
|
|
|
[PREVIOUS REFINED CONTEXT]
|
|
"{prev_refined}"
|
|
|
|
[RECENT SOURCE WINDOW]
|
|
"{prev_raw}"
|
|
|
|
[CURRENT SOURCE WINDOW]
|
|
"{new_raw}"
|
|
"""
|
|
variants.append(("grounded_sermon_editor", system_d, user_d))
|
|
|
|
system_e = """You are a careful live transcript editor working from noisy translated source text.
|
|
|
|
Goal:
|
|
Produce the most faithful readable English update.
|
|
|
|
Decision rules:
|
|
1. NEW SOURCE TEXT is the primary evidence and should dominate the output.
|
|
2. Use PREVIOUS SOURCE TEXT and PREVIOUS REFINED CONTEXT only when they clearly help resolve or continue the new material.
|
|
3. If the new material starts a fresh thought, output only the new material.
|
|
4. Remove exact or near-exact repetition unless the repetition is clearly intentional rhetoric.
|
|
5. Do not add explanations, disclaimers, or meta commentary.
|
|
6. Prefer conservative wording over guessed meaning.
|
|
7. Return only the revised transcript text."""
|
|
user_e = f"""Edit this transcript update.
|
|
|
|
[PREVIOUS REFINED CONTEXT]
|
|
"{prev_refined}"
|
|
|
|
[PREVIOUS SOURCE TEXT]
|
|
"{prev_raw}"
|
|
|
|
[NEW SOURCE TEXT]
|
|
"{new_raw}"
|
|
"""
|
|
variants.append(("grounded_new_dominant", system_e, user_e))
|
|
|
|
system_g = """You are a careful live transcript editor working from noisy translated source text.
|
|
|
|
Goal:
|
|
Produce the most faithful readable English update.
|
|
|
|
Decision rules:
|
|
1. NEW SOURCE TEXT is the primary evidence and should dominate the output.
|
|
2. Use PREVIOUS SOURCE TEXT and PREVIOUS REFINED CONTEXT only when they clearly help resolve or continue the new material.
|
|
3. The final answer must be English only.
|
|
4. If source text contains Spanish, Portuguese, French, or other non-English fragments, translate their meaning into English when reasonably clear.
|
|
5. Never copy non-English words into the output unless they are proper names.
|
|
6. If the new material starts a fresh thought, output only the new material.
|
|
7. Remove exact or near-exact repetition unless the repetition is clearly intentional rhetoric.
|
|
8. Do not add explanations, disclaimers, or meta commentary.
|
|
9. Prefer conservative wording over guessed meaning.
|
|
10. Return only the revised transcript text."""
|
|
user_g = f"""Edit this transcript update.
|
|
|
|
[PREVIOUS REFINED CONTEXT]
|
|
"{prev_refined}"
|
|
|
|
[PREVIOUS SOURCE TEXT]
|
|
"{prev_raw}"
|
|
|
|
[NEW SOURCE TEXT]
|
|
"{new_raw}"
|
|
"""
|
|
variants.append(("grounded_english_only_translate", system_g, user_g))
|
|
|
|
system_h = """You are a careful live transcript editor working from noisy translated source text.
|
|
|
|
Goal:
|
|
Produce the most faithful readable English update.
|
|
|
|
Decision rules:
|
|
1. NEW SOURCE TEXT is the primary evidence and should dominate the output.
|
|
2. Use PREVIOUS SOURCE TEXT and PREVIOUS REFINED CONTEXT only when they clearly help resolve or continue the new material.
|
|
3. The final answer must be English only.
|
|
4. Treat any Spanish, Portuguese, French, or other non-English fragments as contamination from surrounding audio or translation noise unless they clearly repeat the same idea as nearby English.
|
|
5. When a non-English fragment clearly repeats nearby English content, keep only the English meaning once.
|
|
6. Never copy non-English words into the output unless they are proper names.
|
|
7. If the new material starts a fresh thought, output only the new material.
|
|
8. Remove exact or near-exact repetition unless the repetition is clearly intentional rhetoric.
|
|
9. Do not add explanations, disclaimers, or meta commentary.
|
|
10. Prefer conservative wording over guessed meaning.
|
|
11. Return only the revised transcript text."""
|
|
user_h = f"""Edit this transcript update.
|
|
|
|
[PREVIOUS REFINED CONTEXT]
|
|
"{prev_refined}"
|
|
|
|
[PREVIOUS SOURCE TEXT]
|
|
"{prev_raw}"
|
|
|
|
[NEW SOURCE TEXT]
|
|
"{new_raw}"
|
|
"""
|
|
variants.append(("grounded_english_only_ignore_noise", system_h, user_h))
|
|
|
|
system_i = """You are a careful live transcript editor working from noisy translated source text.
|
|
|
|
Goal:
|
|
Produce the most faithful readable English update.
|
|
|
|
Decision rules:
|
|
1. NEW SOURCE TEXT is the primary evidence and should dominate the output.
|
|
2. Use PREVIOUS SOURCE TEXT and PREVIOUS REFINED CONTEXT only when they clearly help resolve or continue the new material.
|
|
3. The final answer must be English only.
|
|
4. Never copy non-English words into the output unless they are proper names.
|
|
5. If a non-English fragment appears as an isolated clause, trailing fragment, or side comment without a clear English anchor, drop it.
|
|
6. Only translate a non-English fragment when it is clearly central to the same idea and its meaning is reasonably obvious from context.
|
|
7. If the new material starts a fresh thought, output only the new material.
|
|
8. Remove exact or near-exact repetition unless the repetition is clearly intentional rhetoric.
|
|
9. Do not add explanations, disclaimers, or meta commentary.
|
|
10. Prefer conservative wording over guessed meaning.
|
|
11. Return only the revised transcript text."""
|
|
user_i = f"""Edit this transcript update.
|
|
|
|
[PREVIOUS REFINED CONTEXT]
|
|
"{prev_refined}"
|
|
|
|
[PREVIOUS SOURCE TEXT]
|
|
"{prev_raw}"
|
|
|
|
[NEW SOURCE TEXT]
|
|
"{new_raw}"
|
|
"""
|
|
variants.append(("grounded_english_only_drop_isolated", system_i, user_i))
|
|
|
|
system_f = """You are a careful live transcript editor working from noisy translated source text.
|
|
|
|
Goal:
|
|
Produce the most faithful readable English update.
|
|
|
|
Decision rules:
|
|
1. NEW SOURCE TEXT is the primary evidence and should dominate the output.
|
|
2. Use PREVIOUS SOURCE TEXT and PREVIOUS REFINED CONTEXT only when they clearly help resolve or continue the new material.
|
|
3. If the new material starts a fresh thought, output only the new material.
|
|
4. Remove exact or near-exact repetition unless the repetition is clearly intentional rhetoric.
|
|
5. If the source repeats the same short clause three or more times in a row, keep at most two repetitions unless losing more would change the speaker's emphasis.
|
|
6. Do not add explanations, disclaimers, or meta commentary.
|
|
7. Prefer conservative wording over guessed meaning.
|
|
8. Return only the revised transcript text."""
|
|
user_f = f"""Edit this transcript update.
|
|
|
|
[PREVIOUS REFINED CONTEXT]
|
|
"{prev_refined}"
|
|
|
|
[PREVIOUS SOURCE TEXT]
|
|
"{prev_raw}"
|
|
|
|
[NEW SOURCE TEXT]
|
|
"{new_raw}"
|
|
"""
|
|
variants.append(("grounded_new_dominant_dedupe", system_f, user_f))
|
|
|
|
if variant_filter:
|
|
allowed = set(x.strip() for x in variant_filter.split(",") if x.strip())
|
|
variants = [variant for variant in variants if variant[0] in allowed]
|
|
return variants
|
|
|
|
|
|
def ollama_generate(model, system_prompt, user_prompt, url, timeout, keep_alive, temperature, top_p, repeat_penalty):
|
|
payload = json.dumps(
|
|
{
|
|
"model": model,
|
|
"prompt": user_prompt,
|
|
"system": system_prompt,
|
|
"stream": False,
|
|
"options": {
|
|
"temperature": temperature,
|
|
"top_p": top_p,
|
|
"repeat_penalty": repeat_penalty,
|
|
},
|
|
"keep_alive": keep_alive,
|
|
}
|
|
).encode("utf-8")
|
|
req = request.Request(
|
|
url,
|
|
data=payload,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with request.urlopen(req, timeout=timeout) as response:
|
|
body = json.loads(response.read().decode("utf-8"))
|
|
except error.HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace")
|
|
raise RuntimeError(f"Ollama HTTP error {exc.code}: {detail}") from exc
|
|
return body.get("response", "").strip()
|
|
|
|
|
|
def tokenize(text):
|
|
return re.findall(r"[a-z0-9']+", (text or "").lower())
|
|
|
|
|
|
def repeated_ngram_ratio(text, n=3):
|
|
tokens = tokenize(text)
|
|
if len(tokens) < n:
|
|
return 0.0
|
|
grams = [tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)]
|
|
counts = Counter(grams)
|
|
repeated = sum(count - 1 for count in counts.values() if count > 1)
|
|
return repeated / max(1, len(grams))
|
|
|
|
|
|
def unsupported_token_ratio(output, evidence):
|
|
evidence_tokens = set(tokenize(evidence))
|
|
output_tokens = tokenize(output)
|
|
if not output_tokens:
|
|
return 0.0
|
|
unsupported = [tok for tok in output_tokens if tok not in evidence_tokens]
|
|
return len(unsupported) / len(output_tokens)
|
|
|
|
|
|
def evaluate_output(output, sample):
|
|
evidence = " ".join(
|
|
part for part in [sample["previous_raw"], sample["new_raw"]] if part
|
|
)
|
|
return {
|
|
"output_len": len(tokenize(output)),
|
|
"repeated_ngram_ratio": round(repeated_ngram_ratio(output), 4),
|
|
"unsupported_token_ratio": round(unsupported_token_ratio(output, evidence), 4),
|
|
"foreign_leak_ratio": round(foreign_leak_ratio(output), 4),
|
|
}
|
|
|
|
|
|
def foreign_leak_ratio(output):
|
|
foreign_markers = {
|
|
"pero", "espanol", "tambien", "dijo", "dios", "responde",
|
|
"mas", "ele", "continuou", "fe",
|
|
"et", "rester", "ferme",
|
|
}
|
|
tokens = tokenize(output)
|
|
if not tokens:
|
|
return 0.0
|
|
leaked = [tok for tok in tokens if tok in foreign_markers]
|
|
return len(leaked) / len(tokens)
|
|
|
|
|
|
def parse_float_grid(raw_value, default_values):
|
|
if not raw_value:
|
|
return default_values
|
|
return [float(x.strip()) for x in raw_value.split(",") if x.strip()]
|
|
|
|
|
|
def build_param_sets(args):
|
|
temperatures = parse_float_grid(args.temperature_grid, [0.1])
|
|
top_ps = parse_float_grid(args.top_p_grid, [0.9])
|
|
repeat_penalties = parse_float_grid(args.repeat_penalty_grid, [1.1])
|
|
|
|
param_sets = []
|
|
for temperature in temperatures:
|
|
for top_p in top_ps:
|
|
for repeat_penalty in repeat_penalties:
|
|
label = f"temp={temperature}_top_p={top_p}_repeat_penalty={repeat_penalty}"
|
|
param_sets.append(
|
|
{
|
|
"label": label,
|
|
"temperature": temperature,
|
|
"top_p": top_p,
|
|
"repeat_penalty": repeat_penalty,
|
|
}
|
|
)
|
|
return param_sets
|
|
|
|
|
|
def run_experiments(args):
|
|
samples = reconstruct_samples(args.log_file)
|
|
samples = maybe_augment_samples(samples, args.sample_mode)
|
|
if args.limit:
|
|
samples = samples[: args.limit]
|
|
|
|
param_sets = build_param_sets(args)
|
|
results = []
|
|
for index, sample in enumerate(samples):
|
|
print(f"Running sample {index + 1}/{len(samples)}")
|
|
sample_results = []
|
|
for variant_name, system_prompt, user_prompt in build_variants(sample, args.variant_filter):
|
|
print(f" variant={variant_name}")
|
|
for params in param_sets:
|
|
print(f" params={params['label']}")
|
|
try:
|
|
output = ollama_generate(
|
|
args.model,
|
|
system_prompt,
|
|
user_prompt,
|
|
args.ollama_url,
|
|
args.timeout,
|
|
args.keep_alive,
|
|
params["temperature"],
|
|
params["top_p"],
|
|
params["repeat_penalty"],
|
|
)
|
|
metrics = evaluate_output(output, sample)
|
|
error_message = None
|
|
except Exception as exc:
|
|
output = ""
|
|
metrics = None
|
|
error_message = str(exc)
|
|
sample_results.append(
|
|
{
|
|
"variant": variant_name,
|
|
"params": params,
|
|
"output": output,
|
|
"metrics": metrics,
|
|
"error": error_message,
|
|
}
|
|
)
|
|
results.append(
|
|
{
|
|
"sample_index": index,
|
|
"sample": sample,
|
|
"results": sample_results,
|
|
}
|
|
)
|
|
return results
|
|
|
|
|
|
def summarize_results(results):
|
|
summary = {}
|
|
for sample_result in results:
|
|
for variant_result in sample_result["results"]:
|
|
summary_key = f"{variant_result['variant']}|{variant_result['params']['label']}"
|
|
bucket = summary.setdefault(
|
|
summary_key,
|
|
{
|
|
"repeated_ngram_ratio": [],
|
|
"unsupported_token_ratio": [],
|
|
"foreign_leak_ratio": [],
|
|
"output_len": [],
|
|
"errors": 0,
|
|
},
|
|
)
|
|
if variant_result.get("error"):
|
|
bucket["errors"] += 1
|
|
continue
|
|
for key, value in variant_result["metrics"].items():
|
|
bucket[key].append(value)
|
|
|
|
for variant_name, metrics in summary.items():
|
|
summary[variant_name] = {
|
|
key: round(statistics.mean(values), 4) if isinstance(values, list) and values else values
|
|
for key, values in metrics.items()
|
|
}
|
|
return summary
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Run prompt experiments against Ollama using distribute_debug.log samples.")
|
|
parser.add_argument("--log-file", type=str, default="distribute_debug.log")
|
|
parser.add_argument("--model", type=str, default="qwen2.5:3b-instruct")
|
|
parser.add_argument("--ollama-url", type=str, default="http://127.0.0.1:11434/api/generate")
|
|
parser.add_argument("--timeout", type=float, default=40.0)
|
|
parser.add_argument("--keep-alive", type=str, default="30m")
|
|
parser.add_argument("--limit", type=int, default=0)
|
|
parser.add_argument("--output-file", type=str, default="logs/prompt_experiment_results.json")
|
|
parser.add_argument("--variant-filter", type=str, default="", help="Comma-separated subset of variants to run.")
|
|
parser.add_argument("--temperature-grid", type=str, default="", help="Comma-separated temperatures to test.")
|
|
parser.add_argument("--top-p-grid", type=str, default="", help="Comma-separated top_p values to test.")
|
|
parser.add_argument("--repeat-penalty-grid", type=str, default="", help="Comma-separated repeat_penalty values to test.")
|
|
parser.add_argument("--sample-mode", type=str, default="base", help="Sample mode: base or multilingual_noise.")
|
|
args = parser.parse_args()
|
|
|
|
results = run_experiments(args)
|
|
summary = summarize_results(results)
|
|
payload = {"model": args.model, "summary": summary, "results": results}
|
|
|
|
output_dir = os.path.dirname(args.output_file)
|
|
if output_dir:
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
with open(args.output_file, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
|
|
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
print(f"Wrote detailed results to {args.output_file}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|