MetaCommit

This commit is contained in:
frostyripper1
2026-05-19 13:37:12 +02:00
parent 0920c70a80
commit 61cd0b889d
31 changed files with 8673 additions and 75 deletions
+463
View File
@@ -0,0 +1,463 @@
// src/training/websearch.rs
#![allow(dead_code)]
use crate::data_ls::schema::EnglishSample;
use anyhow::{anyhow, Result};
use ollama_rs::generation::chat::request::ChatMessageRequest;
use ollama_rs::generation::chat::ChatMessage;
use ollama_rs::generation::completion::request::GenerationRequest;
use ollama_rs::generation::parameters::{FormatType, KeepAlive};
use ollama_rs::generation::embeddings::request::{EmbeddingsInput, GenerateEmbeddingsRequest};
use ollama_rs::models::ModelOptions;
use ollama_rs::Ollama;
use reqwest::{header, Client};
use scraper::{Html, Selector};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use urlencoding::encode;
fn extract_json_from_response(text: &str) -> Option<String> {
let trimmed = text.trim();
let without_fence = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```json "))
.or_else(|| trimmed.strip_prefix("```json\n"))
.or_else(|| trimmed.strip_prefix("```"))
.unwrap_or(trimmed)
.trim_start();
let without_trailing = without_fence
.strip_suffix("```")
.or_else(|| without_fence.strip_suffix("```\n"))
.or_else(|| without_fence.strip_suffix("``` "))
.unwrap_or(without_fence)
.trim();
let start = without_trailing.find('{')?;
let end = without_trailing.rfind('}')?;
if start >= end {
return None;
}
let json_str = &without_trailing[start..=end];
if json_str.starts_with('{') && json_str.ends_with('}') {
Some(json_str.to_string())
} else {
None
}
}
fn is_json_braces_balanced(text: &str) -> bool {
let mut balance = 0;
for c in text.chars() {
if c == '{' {
balance += 1;
} else if c == '}' {
balance -= 1;
if balance < 0 {
return false;
}
}
}
balance == 0
}
fn repair_json_braces(json: &str) -> String {
let mut repaired = json.trim_end().to_string();
let mut balance = 0;
for c in repaired.chars() {
if c == '{' {
balance += 1;
} else if c == '}' {
balance -= 1;
}
}
while balance > 0 {
repaired.push('}');
balance -= 1;
}
repaired
}
fn clean_output(output: &str) -> String {
let without_url_spaces = output
.replace("http ://", "http://")
.replace("https ://", "https://")
.replace(" : / /", "://");
without_url_spaces
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn validate_sample(sample: &EnglishSample) -> Result<()> {
let instruction_lower = sample.instruction.to_lowercase();
let forbidden_phrases = ["structured learning sample", "valid json format"];
for forbidden in forbidden_phrases {
if instruction_lower.contains(forbidden) {
return Err(anyhow!(
"Parsed instruction contains forbidden meta-language: '{}'",
forbidden
));
}
}
if sample.output.trim().is_empty() {
return Err(anyhow!(
"Parsed sample output is empty. The model must produce a direct answer or summary."
));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchEngine {
Dolphin,
DuckDuckGo,
Deepseek,
Qwen,
Grok,
}
impl std::fmt::Display for SearchEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
SearchEngine::Dolphin => "Dolphin Chat",
SearchEngine::DuckDuckGo => "DuckDuckGo",
SearchEngine::Deepseek => "Deepseek",
SearchEngine::Qwen => "Qwen",
SearchEngine::Grok => "Grok",
}
)
}
}
impl SearchEngine {
pub fn search_url(&self, query: &str) -> String {
match self {
SearchEngine::Dolphin => format!("https://chat.dphn.ai/?q={}", encode(query)),
SearchEngine::DuckDuckGo => {
format!("https://html.duckduckgo.com/html/?q={}", encode(query))
}
SearchEngine::Deepseek => format!("https://deepseek.ai/search?q={}", encode(query)),
SearchEngine::Qwen => format!("https://qwen.ai/search?q={}", encode(query)),
SearchEngine::Grok => format!("https://grok.com/search?q={}", encode(query)),
}
}
}
fn create_browser_client() -> Result<Client, reqwest::Error> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static(
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
),
);
headers.insert(
header::ACCEPT_LANGUAGE,
header::HeaderValue::from_static("en-US,en;q=0.9"),
);
headers.insert(
header::CONNECTION,
header::HeaderValue::from_static("keep-alive"),
);
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"),
);
Client::builder().default_headers(headers).build()
}
pub async fn web_search(query: &str, engine: SearchEngine) -> Result<String> {
let url = engine.search_url(query);
let client = create_browser_client()?;
let body = client
.get(&url)
.header(header::REFERER, "https://chat.dphn.ai/")
.send()
.await?
.error_for_status()?
.text()
.await?;
let doc = Html::parse_document(&body);
let snippets: Vec<String> = match engine {
SearchEngine::Dolphin => {
let selector = Selector::parse("p, div, span").unwrap();
let mut snippets: Vec<String> = doc
.select(&selector)
.filter_map(|el| {
let text = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
if text.len() >= 20 && text.len() <= 300 {
Some(text)
} else {
None
}
})
.take(5)
.collect();
if snippets.is_empty() {
let body_text = doc.root_element().text().collect::<Vec<_>>().join(" ");
snippets = body_text
.split_terminator(|c| c == '.' || c == '!' || c == '?')
.map(str::trim)
.filter(|text| text.len() >= 20 && text.len() <= 300)
.take(5)
.map(String::from)
.collect();
}
snippets
}
SearchEngine::DuckDuckGo => {
let selector = Selector::parse(".result__snippet").unwrap();
doc.select(&selector)
.take(5)
.map(|el| el.inner_html())
.collect()
}
SearchEngine::Deepseek => {
let selector = Selector::parse(".snippet, .search-result, .result, p").unwrap();
doc.select(&selector)
.filter_map(|el| {
let text = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
if text.len() >= 20 && text.len() <= 300 {
Some(text)
} else {
None
}
})
.take(5)
.collect()
}
SearchEngine::Qwen => {
let selector = Selector::parse(".search-result, .result, p").unwrap();
doc.select(&selector)
.filter_map(|el| {
let text = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
if text.len() >= 20 && text.len() <= 300 {
Some(text)
} else {
None
}
})
.take(5)
.collect()
}
SearchEngine::Grok => {
let selector = Selector::parse(".search-result, .result, p").unwrap();
doc.select(&selector)
.filter_map(|el| {
let text = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
if text.len() >= 20 && text.len() <= 300 {
Some(text)
} else {
None
}
})
.take(5)
.collect()
}
};
if snippets.is_empty() {
return Err(anyhow!(
"{} returned no usable results from {}",
engine,
url
));
}
Ok(format!("{}\n\nSearch page: {}", snippets.join(" "), url))
}
/// Use LLM to convert search result into a structured EnglishSample
pub async fn derive_sample_from_search(
query: &str,
snippets: &str,
ollama: &Ollama,
) -> Result<EnglishSample> {
let prompt = format!(
"Convert the following search result into a structured learning sample in JSON format.\n\
Use this exact schema:\n\
{{\"type\": \"instruction_following\", \"instruction\": \"{}\", \"input\": \"\", \"reasoning\": \"...\", \"output\": \"...\"}}\n\n\
Query: {}\nSearch result: {}\n\nOnly output valid JSON, no extra text. Fill in the reasoning and output based on the search result. The instruction should be the query itself or a rephrased version. The type is always \"instruction_following\".",
query, query, snippets
);
let req = ChatMessageRequest::new("sadiq-bd/llama3.2-3b-uncensored".to_string(), vec![ChatMessage::user(prompt)])
.options(
ModelOptions::default()
.num_ctx(8192)
.num_thread(8)
.num_predict(1024)
.temperature(0.85),
)
.keep_alive(KeepAlive::Indefinitely);
let resp = ollama.send_chat_messages(req).await?;
let json_str = resp.message.content;
let sample: EnglishSample = serde_json::from_str(&json_str)?;
Ok(sample)
}
/// Convert a large PDF text into a sequence of structured EnglishSample objects.
pub async fn derive_samples_from_pdf_text(
pdf_path: &Path,
text_chunks: &[String],
ollama: &Ollama,
model_name: &str,
) -> Result<Vec<EnglishSample>> {
let mut samples = Vec::new();
let total = text_chunks.len();
for (index, chunk) in text_chunks.iter().enumerate() {
let prompt = format!(
"Extract ONE learning sample from the following text in {}.\n\
Use this exact schema: {{\"type\": \"instruction_following\", \"instruction\": \"...\", \"input\": \"\", \"reasoning\": \"...\", \"output\": \"...\"}}.\n\
The instruction must be a specific technical question or task based *only* on the text.\n\
The output must be a direct answer or summary (no empty strings unless truly absent).\n\
Do NOT include any meta-dialogue, do NOT describe the format, and do NOT output anything except the JSON object.\n\nText:\n{}",
pdf_path.file_name().unwrap_or_default().to_string_lossy(),
chunk
);
let opts = ModelOptions::default()
.num_ctx(8192)
.num_thread(8)
.num_predict(2048)
.temperature(0.85);
let request = GenerationRequest::new(model_name.to_string(), prompt.clone())
.format(FormatType::Json)
.options(opts.clone());
let resp = match ollama.generate(request).await {
Ok(r) => r,
Err(e) if model_name == "deepseek-r1:7b" => {
eprintln!(
"⚠️ deepseek-r1:7b failed, falling back to qwen2.5-coder:1.5b. Error: {}",
e
);
let fallback_req = GenerationRequest::new("qwen2.5-coder:1.5b".to_string(), prompt.clone())
.format(FormatType::Json)
.options(opts.clone());
match ollama.generate(fallback_req).await {
Ok(r) => r,
Err(_) => {
eprintln!(
"⚠️ qwen2.5-coder:1.5b also failed; falling back to sadiq-bd/llama3.2-3b-uncensored."
);
let fallback_req = GenerationRequest::new("sadiq-bd/llama3.2-3b-uncensored".to_string(), prompt)
.format(FormatType::Json)
.options(opts);
ollama.generate(fallback_req).await?
}
}
}
Err(e) if model_name == "qwen2.5-coder:1.5b" => {
eprintln!(
"⚠️ qwen2.5-coder:1.5b failed, falling back to sadiq-bd/llama3.2-3b-uncensored. Error: {}",
e
);
let fallback_req = GenerationRequest::new("sadiq-bd/llama3.2-3b-uncensored".to_string(), prompt)
.format(FormatType::Json)
.options(opts);
ollama.generate(fallback_req).await?
}
Err(e) => return Err(e.into()),
};
let raw_response = resp.response;
let mut json_candidate = extract_json_from_response(&raw_response)
.unwrap_or_else(|| raw_response.trim().to_string());
if !is_json_braces_balanced(&json_candidate) {
json_candidate = repair_json_braces(&json_candidate);
}
let sample: EnglishSample = match serde_json::from_str::<EnglishSample>(&json_candidate) {
Ok(mut s) => {
s.output = clean_output(&s.output);
s
}
Err(err) => {
let repaired_candidate = repair_json_braces(&json_candidate);
if repaired_candidate != json_candidate {
serde_json::from_str(&repaired_candidate).map_err(|second_err| {
anyhow::anyhow!(
"Failed to parse JSON from PDF chunk {} of {}: {}\nRetry parse error: {}\nCandidate: {}\nRepaired: {}\nRaw response: {}",
index + 1,
total,
err,
second_err,
json_candidate,
repaired_candidate,
raw_response
)
})?
} else if let Some(cleaned) = extract_json_from_response(&raw_response) {
serde_json::from_str(&cleaned).map_err(|second_err| {
anyhow::anyhow!(
"Failed to parse JSON from PDF chunk {} of {}: {}\nRetry parse error: {}\nCleaned response: {}\nRaw response: {}",
index + 1,
total,
err,
second_err,
cleaned,
raw_response
)
})?
} else {
return Err(anyhow::anyhow!(
"Failed to parse JSON from PDF chunk {} of {}.\nRaw response:\n{}",
index + 1,
total,
raw_response
));
}
}
};
validate_sample(&sample).map_err(|validation_err| {
anyhow::anyhow!(
"Validation failed for PDF chunk {} of {}: {}\nCandidate: {}\nRaw response: {}",
index + 1,
total,
validation_err,
json_candidate,
raw_response
)
})?;
samples.push(sample);
}
Ok(samples)
}
/// Append a new sample to the dynamic knowledge file
pub fn append_sample_to_jsonl(sample: &EnglishSample, path: &str) -> Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
let line = serde_json::to_string(sample)?;
writeln!(file, "{}", line)?;
Ok(())
}
/// Compute embedding for a sample (so we can add it to inmemory vectors)
pub async fn compute_sample_embedding(
sample: &EnglishSample,
embed_model: &str,
ollama: &Ollama,
) -> Result<Vec<f32>> {
let text = format!(
"Instruction: {}\nOutput: {}",
sample.instruction, sample.output
);
let req =
GenerateEmbeddingsRequest::new(embed_model.to_string(), EmbeddingsInput::Single(text))
.keep_alive(KeepAlive::Indefinitely);
let resp = ollama.generate_embeddings(req).await?;
Ok(resp.embeddings.into_iter().next().unwrap_or_default())
}