Update funpack.py
This commit is contained in:
+202
-85
@@ -172,7 +172,7 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
text = key if isinstance(key, str) else tokenizer.decode([int(key)], skip_special_tokens=True).strip()
|
||||
if text:
|
||||
top_list.append(f"{text}({score:.2f})")
|
||||
except:
|
||||
except Exception:
|
||||
top_list.append(f"{key}({score:.2f})")
|
||||
return ", ".join(top_list) if top_list else "None"
|
||||
|
||||
@@ -183,15 +183,93 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
if '<' in t or '>' in t or len(t) < 3:
|
||||
return False
|
||||
t_lower = t.lower()
|
||||
stopwords = {"the","a","an","and","or","but","with","for","of","in","on","at","to","from","by","is","are","was","were","be","been","being","have","has","had","do","does","did","will","would","her","his","him","she","he","it","they","them","this","that","these","those","i","you","my","your","our","their","me","us"}
|
||||
stopwords = {
|
||||
"the", "a", "an", "and", "or", "but", "with", "for", "of", "in", "on", "at",
|
||||
"to", "from", "by", "is", "are", "was", "were", "be", "been", "being", "have",
|
||||
"has", "had", "do", "does", "did", "will", "would", "her", "his", "him", "she",
|
||||
"he", "it", "they", "them", "this", "that", "these", "those", "i", "you", "my",
|
||||
"your", "our", "their", "me", "us"
|
||||
}
|
||||
if t_lower in stopwords:
|
||||
return False
|
||||
if t in {",",".","!","?",":",";","-","*","(",")","[","]","{","}","'","\"", "..."} or t.isdigit():
|
||||
if t in {",", ".", "!", "?", ":", ";", "-", "*", "(", ")", "[", "]", "{", "}", "'", "\"", "..."} or t.isdigit():
|
||||
return False
|
||||
if not any(c.isalpha() for c in t):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _parse_concepts(self, prompt: str):
|
||||
"""
|
||||
Split a prompt into concept phrases on commas/semicolons.
|
||||
Each phrase is returned as a list of significant lowercase words.
|
||||
Example: "red dress, dark forest" -> [["red","dress"], ["dark","forest"]]
|
||||
"""
|
||||
if not prompt:
|
||||
return []
|
||||
phrases = [p.strip() for p in re.split(r'[,;]', prompt) if p.strip()]
|
||||
result = []
|
||||
for phrase in phrases:
|
||||
words = [w.strip().lower() for w in phrase.split() if self._is_valuable_token(w.strip())]
|
||||
if words:
|
||||
result.append(words)
|
||||
return result
|
||||
|
||||
def _match_concept(self, phrase_words: list, concept_clusters: dict, threshold: float = 0.38):
|
||||
"""
|
||||
Find the best-matching cluster for a phrase via Jaccard similarity.
|
||||
Returns (cluster_id, is_new_cluster).
|
||||
If no existing cluster exceeds the threshold, a new cluster id is minted.
|
||||
Lower threshold -> more clusters (stricter separation).
|
||||
Higher threshold -> fewer clusters (more aggressive merging).
|
||||
"""
|
||||
if not phrase_words:
|
||||
return None, False
|
||||
phrase_set = set(phrase_words)
|
||||
best_id, best_score = None, 0.0
|
||||
for cid, cluster in concept_clusters.items():
|
||||
anchor_set = set(cluster.get("anchor_words", []))
|
||||
if not anchor_set:
|
||||
continue
|
||||
union = len(phrase_set | anchor_set)
|
||||
if union == 0:
|
||||
continue
|
||||
jaccard = len(phrase_set & anchor_set) / union
|
||||
if jaccard > best_score:
|
||||
best_score, best_id = jaccard, cid
|
||||
if best_score >= threshold:
|
||||
return best_id, False
|
||||
new_id = md5("|".join(sorted(phrase_words)).encode()).hexdigest()[:10]
|
||||
return new_id, True
|
||||
|
||||
def _build_word_concept_map(self, prompt: str, concept_clusters: dict):
|
||||
"""
|
||||
Parse the prompt into concept phrases, match or create clusters,
|
||||
and return a dict mapping each significant word -> cluster_id.
|
||||
Updates concept_clusters in-place with any new or expanded clusters.
|
||||
"""
|
||||
word_to_concept = {}
|
||||
for phrase_words in self._parse_concepts(prompt):
|
||||
cid, is_new = self._match_concept(phrase_words, concept_clusters)
|
||||
if cid is None:
|
||||
continue
|
||||
if is_new:
|
||||
concept_clusters[cid] = {
|
||||
"label": " ".join(phrase_words[:5]),
|
||||
"anchor_words": phrase_words,
|
||||
"word_importance": {},
|
||||
"usage_count": 0,
|
||||
"last_seen_iter": 0,
|
||||
}
|
||||
else:
|
||||
# Expand anchor vocabulary with any new words seen in this phrase
|
||||
existing = set(concept_clusters[cid]["anchor_words"])
|
||||
for w in phrase_words:
|
||||
if w not in existing:
|
||||
concept_clusters[cid]["anchor_words"].append(w)
|
||||
for w in phrase_words:
|
||||
word_to_concept[w] = cid
|
||||
return word_to_concept
|
||||
|
||||
def refine(self, positive_conditioning, rating: int, refinement_key: str, scheduler_mode: str = "original",
|
||||
positive_prompt: str = "",
|
||||
reset_session: bool = False, unlimited_history: bool = False,
|
||||
@@ -225,28 +303,33 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
|
||||
prompt_key = positive_prompt
|
||||
|
||||
# ====================== RESET / NEW SESSION ======================
|
||||
if reset_session or not os.path.exists(json_file):
|
||||
data = {
|
||||
# ====================== FRESH GLOBAL STATE TEMPLATE ======================
|
||||
# Defined once here and reused for both reset and corrupt-file recovery.
|
||||
# Removed: infusion_tokens, token_library — initialised but never read or written
|
||||
# after construction, contributing only to JSON bloat.
|
||||
def _fresh_global():
|
||||
return {
|
||||
"word_importance": {},
|
||||
"concept_clusters": {},
|
||||
"exploration_base": 0.08,
|
||||
"momentum": None,
|
||||
"avg_reward_ema": 0.0,
|
||||
"good_ratio": 0.0,
|
||||
"dynamic_sim_threshold": 0.82,
|
||||
"last_feedback_word": None,
|
||||
"feedbacked_words": [],
|
||||
"scheduler_mode": scheduler_mode,
|
||||
"prodigy_d": {},
|
||||
"prodigy_lr_base": 1.0,
|
||||
"warmup_steps": 8,
|
||||
"total_steps_estimate": 150,
|
||||
"current_step": 0,
|
||||
}
|
||||
|
||||
def _fresh_data():
|
||||
return {
|
||||
"refinement_key": refinement_key,
|
||||
"global_adaptive": {
|
||||
"word_importance": {},
|
||||
"infusion_tokens": {},
|
||||
"token_library": {},
|
||||
"exploration_base": 0.08,
|
||||
"momentum": None,
|
||||
"avg_reward_ema": 0.0,
|
||||
"good_ratio": 0.0,
|
||||
"dynamic_sim_threshold": 0.82,
|
||||
"last_feedback_word": None,
|
||||
"feedbacked_words": [],
|
||||
"scheduler_mode": scheduler_mode,
|
||||
"prodigy_d": {},
|
||||
"prodigy_lr_base": 1.0,
|
||||
"warmup_steps": 8,
|
||||
"total_steps_estimate": 150,
|
||||
"current_step": 0,
|
||||
},
|
||||
"global_adaptive": _fresh_global(),
|
||||
"prompt_histories": {
|
||||
prompt_key: {
|
||||
"reference_embeds": tensor_to_serializable(raw_positive),
|
||||
@@ -257,11 +340,15 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
"last_prompt_key": prompt_key,
|
||||
"pending_feedback": None
|
||||
}
|
||||
|
||||
# ====================== RESET / NEW SESSION ======================
|
||||
if reset_session or not os.path.exists(json_file):
|
||||
data = _fresh_data()
|
||||
with open(json_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
return (positive_conditioning, "✓ New session started – Reference saved", "", "✓ New session started. Reference embedding saved.")
|
||||
|
||||
# Safe JSON loading
|
||||
# ====================== SAFE JSON LOAD ======================
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
@@ -271,41 +358,16 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
os.remove(json_file)
|
||||
except OSError:
|
||||
pass
|
||||
data = {
|
||||
"refinement_key": refinement_key,
|
||||
"global_adaptive": {
|
||||
"word_importance": {},
|
||||
"infusion_tokens": {},
|
||||
"token_library": {},
|
||||
"exploration_base": 0.08,
|
||||
"momentum": None,
|
||||
"avg_reward_ema": 0.0,
|
||||
"good_ratio": 0.0,
|
||||
"dynamic_sim_threshold": 0.82,
|
||||
"last_feedback_word": None,
|
||||
"feedbacked_words": [],
|
||||
"scheduler_mode": scheduler_mode,
|
||||
"prodigy_d": {},
|
||||
"prodigy_lr_base": 1.0,
|
||||
"warmup_steps": 8,
|
||||
"total_steps_estimate": 150,
|
||||
"current_step": 0,
|
||||
},
|
||||
"prompt_histories": {
|
||||
prompt_key: {
|
||||
"reference_embeds": tensor_to_serializable(raw_positive),
|
||||
"history": [],
|
||||
"last_rating": rating
|
||||
}
|
||||
},
|
||||
"last_prompt_key": prompt_key,
|
||||
"pending_feedback": None
|
||||
}
|
||||
data = _fresh_data()
|
||||
with open(json_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
return (positive_conditioning, "⚠️ Session file was corrupt – Reset and started fresh", "", "⚠️ Session reset due to corrupt file")
|
||||
|
||||
global_adaptive = data["global_adaptive"]
|
||||
# Migrate sessions created before concept-awareness was added
|
||||
global_adaptive.setdefault("concept_clusters", {})
|
||||
global_adaptive.setdefault("word_importance", {})
|
||||
|
||||
prompt_histories = data.get("prompt_histories", {})
|
||||
tokenizer = self._get_tokenizer()
|
||||
|
||||
@@ -322,13 +384,21 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
if pending is not None:
|
||||
full_word = pending["full_word"].lower()
|
||||
word_importance = global_adaptive["word_importance"]
|
||||
concept_clusters = global_adaptive["concept_clusters"]
|
||||
old_imp = word_importance.get(full_word, 1.0)
|
||||
if feedback_rating == 6:
|
||||
target_imp = 0.65
|
||||
else:
|
||||
target_imp = 0.4 + (feedback_rating / 5.0) * 2.1
|
||||
new_imp = 0.65 * old_imp + 0.35 * target_imp
|
||||
word_importance[full_word] = max(0.3, min(2.8, new_imp))
|
||||
clamped = max(0.3, min(2.8, new_imp))
|
||||
word_importance[full_word] = clamped
|
||||
# Mirror feedback into the concept cluster the word belongs to
|
||||
pending_cid = pending.get("concept_id")
|
||||
if pending_cid and pending_cid in concept_clusters:
|
||||
local_imp = concept_clusters[pending_cid]["word_importance"]
|
||||
old_local = local_imp.get(full_word, 1.0)
|
||||
local_imp[full_word] = max(0.3, min(2.8, 0.65 * old_local + 0.35 * target_imp))
|
||||
data["pending_feedback"] = None
|
||||
with open(json_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
@@ -373,18 +443,16 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
|
||||
# ====================== WORD GROUPING ======================
|
||||
word_groups = []
|
||||
seen = set()
|
||||
grouped_seen = set()
|
||||
if tokenizer and positive_prompt:
|
||||
raw_words = [w.strip() for w in positive_prompt.split() if w.strip()]
|
||||
for word in raw_words:
|
||||
clean_word = word
|
||||
lower = clean_word.lower()
|
||||
if lower in seen or len(clean_word) < 3 or not self._is_valuable_token(clean_word):
|
||||
if lower in grouped_seen or len(clean_word) < 3 or not self._is_valuable_token(clean_word):
|
||||
continue
|
||||
seen.add(lower)
|
||||
|
||||
grouped_seen.add(lower)
|
||||
word_token_list = tokenizer.encode(clean_word, add_special_tokens=False)
|
||||
|
||||
found = False
|
||||
for start in range(len(full_token_ids) - len(word_token_list) + 1):
|
||||
if full_token_ids[start:start + len(word_token_list)] == word_token_list:
|
||||
@@ -394,6 +462,10 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
if not found and word_token_list:
|
||||
word_groups.append((0, 1, clean_word, [word_token_list[0]]))
|
||||
|
||||
# ====================== CONCEPT CLUSTER SETUP ======================
|
||||
concept_clusters = global_adaptive["concept_clusters"]
|
||||
word_to_concept = self._build_word_concept_map(positive_prompt, concept_clusters) if positive_prompt else {}
|
||||
|
||||
# ====================== SCHEDULER SETUP ======================
|
||||
global_adaptive["scheduler_mode"] = scheduler_mode
|
||||
history = active.get("history", [])
|
||||
@@ -419,7 +491,7 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
last_rating = active.get("last_rating", 5)
|
||||
last_reward = (last_rating - 5.5) / 4.5
|
||||
|
||||
word_importance = global_adaptive.setdefault("word_importance", {})
|
||||
word_importance = global_adaptive["word_importance"]
|
||||
|
||||
lr_scale, confidence, exploration_mult, word_lr_mult = self._get_scheduler_factors(
|
||||
scheduler_mode, rating, reward, similarity, iter_num, total_iters,
|
||||
@@ -428,21 +500,32 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
|
||||
expl = global_adaptive["exploration_base"] * exploration_mult
|
||||
|
||||
# ====================== WORD IMPORTANCE UPDATE ======================
|
||||
# ====================== WORD IMPORTANCE UPDATE (concept-aware) ======================
|
||||
for start, end, full_word, word_token_list in word_groups:
|
||||
if not self._is_valuable_token(full_word):
|
||||
continue
|
||||
wkey = full_word.lower()
|
||||
if wkey not in word_importance:
|
||||
word_importance[wkey] = 1.0
|
||||
|
||||
base_lr = 0.22 / (1 + 0.07 * (len(history) ** 0.5))
|
||||
group_delta = reward * base_lr * lr_scale * confidence
|
||||
|
||||
if wkey in word_lr_mult:
|
||||
group_delta *= word_lr_mult[wkey]
|
||||
|
||||
word_importance[wkey] = max(0.35, min(2.8, word_importance[wkey] + group_delta))
|
||||
# Primary signal: concept-local importance, isolated per cluster
|
||||
cid = word_to_concept.get(wkey)
|
||||
if cid and cid in concept_clusters:
|
||||
local_imp = concept_clusters[cid]["word_importance"]
|
||||
if wkey not in local_imp:
|
||||
local_imp[wkey] = 1.0
|
||||
local_imp[wkey] = max(0.35, min(2.8, local_imp[wkey] + group_delta))
|
||||
concept_clusters[cid]["usage_count"] = concept_clusters[cid].get("usage_count", 0) + 1
|
||||
concept_clusters[cid]["last_seen_iter"] = iter_num
|
||||
|
||||
# Fallback: global word_importance, dampened so the scheduler/prodigy
|
||||
# still has a signal without contaminating cross-context words
|
||||
if wkey not in word_importance:
|
||||
word_importance[wkey] = 1.0
|
||||
word_importance[wkey] = max(0.35, min(2.8, word_importance[wkey] + group_delta * 0.4))
|
||||
|
||||
# ====================== CORE REFINEMENT ======================
|
||||
reference = cur_positive.clone()
|
||||
@@ -471,7 +554,6 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
sim_threshold = global_adaptive["dynamic_sim_threshold"]
|
||||
is_close = (not is_new_prompt) and (similarity >= sim_threshold)
|
||||
|
||||
# Always define new_delta
|
||||
if is_close and history:
|
||||
last_entry = history[-1]
|
||||
mod_data = last_entry.get("modified_embeds")
|
||||
@@ -508,12 +590,19 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
else:
|
||||
new_delta = torch.randn_like(reference) * expl * 0.45
|
||||
|
||||
# Apply word-level importance
|
||||
if word_importance and cur_positive.dim() > 1:
|
||||
# Apply concept-aware word importance to delta
|
||||
if cur_positive.dim() > 1:
|
||||
seq_len = cur_positive.shape[1] if cur_positive.dim() == 3 else cur_positive.shape[0]
|
||||
importance_tensor = torch.ones(seq_len, device=device)
|
||||
for start, end, full_word, _ in word_groups:
|
||||
imp = word_importance.get(full_word.lower(), 1.0)
|
||||
wkey = full_word.lower()
|
||||
cid = word_to_concept.get(wkey)
|
||||
if cid and cid in concept_clusters:
|
||||
# Use concept-local score; fall back to global if not yet tracked locally
|
||||
imp = concept_clusters[cid]["word_importance"].get(wkey,
|
||||
word_importance.get(wkey, 1.0))
|
||||
else:
|
||||
imp = word_importance.get(wkey, 1.0)
|
||||
importance_tensor[start:end] = imp
|
||||
new_delta = new_delta * importance_tensor.unsqueeze(-1)
|
||||
|
||||
@@ -535,6 +624,14 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
global_adaptive["dynamic_sim_threshold"] = sim_threshold
|
||||
global_adaptive["exploration_base"] = expl
|
||||
|
||||
# Prune stale concept clusters to prevent unbounded JSON growth
|
||||
if len(concept_clusters) > 64:
|
||||
concept_clusters = {
|
||||
cid: c for cid, c in concept_clusters.items()
|
||||
if iter_num - c.get("last_seen_iter", 0) < 500
|
||||
}
|
||||
global_adaptive["concept_clusters"] = concept_clusters
|
||||
|
||||
# ====================== HISTORY ENTRY ======================
|
||||
history_entry = {
|
||||
"iteration": iter_num,
|
||||
@@ -550,8 +647,8 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
sorted_hist = sorted(history, key=lambda x: x.get("rating", 0), reverse=True)
|
||||
top = sorted_hist[:40]
|
||||
recent = history[-120:]
|
||||
seen = {e["iteration"] for e in top}
|
||||
history = top + [e for e in recent if e["iteration"] not in seen]
|
||||
seen_iters = {e["iteration"] for e in top}
|
||||
history = top + [e for e in recent if e["iteration"] not in seen_iters]
|
||||
|
||||
active["history"] = history
|
||||
active["last_rating"] = rating
|
||||
@@ -568,28 +665,40 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
if not self._is_valuable_token(full_word) or full_word.lower() in feedbacked:
|
||||
continue
|
||||
wkey = full_word.lower()
|
||||
imp = word_importance.get(wkey, 1.0)
|
||||
cid = word_to_concept.get(wkey)
|
||||
if cid and cid in concept_clusters:
|
||||
imp = concept_clusters[cid]["word_importance"].get(wkey, 1.0)
|
||||
else:
|
||||
imp = word_importance.get(wkey, 1.0)
|
||||
# Score: words whose importance is still near 1.0 (uncertain) are most
|
||||
# valuable to get explicit human feedback on
|
||||
score = 1.0 - abs(imp - 1.0)
|
||||
candidates.append((full_word, wkey, score, word_token_list))
|
||||
candidates.append((full_word, wkey, score, word_token_list, cid))
|
||||
|
||||
if candidates:
|
||||
chosen = max(candidates, key=lambda x: x[2])
|
||||
full_word, wkey, _, word_token_list = chosen
|
||||
|
||||
full_word, wkey, _, word_token_list, chosen_cid = chosen
|
||||
data["pending_feedback"] = {
|
||||
"full_word": full_word,
|
||||
"word_token_list": word_token_list,
|
||||
"concept_id": chosen_cid,
|
||||
"prompt_key": prompt_key,
|
||||
"iteration": iter_num
|
||||
}
|
||||
global_adaptive["last_feedback_word"] = full_word
|
||||
global_adaptive.setdefault("feedbacked_words", []).append(full_word)
|
||||
|
||||
feedback_question_output = f"Word: '{full_word}' – How well represented? (1=absent, 5=perfect, 6=overrepresented)"
|
||||
|
||||
# ====================== TRAINING INFO ======================
|
||||
current_top = self._get_top_tokens(word_importance, tokenizer, 12)
|
||||
|
||||
active_concept_parts = []
|
||||
for cid, c in concept_clusters.items():
|
||||
if cid in word_to_concept.values():
|
||||
top_local = self._get_top_tokens(c["word_importance"], tokenizer, 5)
|
||||
active_concept_parts.append(f"[{c['label']}({c['usage_count']}): {top_local}]")
|
||||
concept_line = "; ".join(active_concept_parts) if active_concept_parts else "none"
|
||||
|
||||
normalized_reward = max(0.0, min(1.0, (avg_reward_ema + 1.0) / 2.0))
|
||||
stability_factor = max(0.0, 1.0 - similarity)
|
||||
raw_loss = (1.0 - normalized_reward) * 0.7 + stability_factor * 0.3
|
||||
@@ -600,7 +709,8 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
f"EMA Reward: {avg_reward_ema:+.3f} | Confidence: {confidence:.2f} | LR Scale: {lr_scale:.3f}\n"
|
||||
f"Exploration: {expl:.3f} | Similarity: {similarity:.4f} | Good Ratio: {good_ratio:.1%}\n"
|
||||
f"**Learning Loss: {learning_loss:.4f}** (lower is better)\n"
|
||||
f"Top Word Importance: {current_top}\n"
|
||||
f"Global Top Words: {current_top}\n"
|
||||
f"Active Concept Clusters ({len(concept_clusters)} total): {concept_line}\n"
|
||||
f"Word Library Size: {len(word_importance)}"
|
||||
)
|
||||
|
||||
@@ -611,13 +721,20 @@ class FunPackGemmaEmbeddingRefiner:
|
||||
|
||||
# ====================== STATUS ======================
|
||||
trend = "↑" if reward > last_reward else "↓" if reward < last_reward else "→"
|
||||
health = "🚀 Strong convergence" if avg_reward_ema > 0.6 else "✅ Learning well" if avg_reward_ema > 0.3 else "⚠️ Still exploring" if avg_reward_ema > -0.2 else "🔄 Heavy correction"
|
||||
health = (
|
||||
"🚀 Strong convergence" if avg_reward_ema > 0.6 else
|
||||
"✅ Learning well" if avg_reward_ema > 0.3 else
|
||||
"⚠️ Still exploring" if avg_reward_ema > -0.2 else
|
||||
"🔄 Heavy correction"
|
||||
)
|
||||
|
||||
status = (f"Iter {iter_num} | Total iters {total_iters} | "
|
||||
f"History {len(history)} | Unique prompts {len(prompt_histories)}\n"
|
||||
f"Rating {rating}/10 {trend} | Sim {similarity:.3f} | Reward {reward:+.2f} | "
|
||||
f"EMA {avg_reward_ema:+.2f} | Good ratio {good_ratio:.0%} | Expl {expl:.3f} | {health}\n"
|
||||
f"Current focus: {current_top}")
|
||||
status = (
|
||||
f"Iter {iter_num} | Total iters {total_iters} | "
|
||||
f"History {len(history)} | Unique prompts {len(prompt_histories)}\n"
|
||||
f"Rating {rating}/10 {trend} | Sim {similarity:.3f} | Reward {reward:+.2f} | "
|
||||
f"EMA {avg_reward_ema:+.2f} | Good ratio {good_ratio:.0%} | Expl {expl:.3f} | {health}\n"
|
||||
f"Active concepts: {concept_line}"
|
||||
)
|
||||
|
||||
# ====================== FINAL SAVE ======================
|
||||
with open(json_file, "w", encoding="utf-8") as f:
|
||||
|
||||
Reference in New Issue
Block a user