Skip to content

Commit cb5fff1

Browse files
committed
tweak llm and add list voice utils
1 parent ba9ec6d commit cb5fff1

File tree

3 files changed

+123
-40
lines changed

3 files changed

+123
-40
lines changed

psyflow/LLM.py

Lines changed: 74 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -503,26 +503,19 @@ def translate(
503503
max_tokens: int = 800
504504
) -> str:
505505
"""
506-
Translate arbitrary text into the target language, preserving formatting.
507-
508-
:param text: The text to translate.
509-
:param target_language: Language to translate into (e.g. "Chinese").
510-
:param prompt: Optional custom instruction. If None, a default
511-
“Translate the following text into X…” prompt is used.
512-
:param deterministic: If True, forces deterministic decoding (temp=0, top_p=1).
513-
:param temperature: Sampling temperature (ignored if deterministic).
514-
:param max_tokens: Maximum tokens to generate in the translation.
515-
:return: The translated text.
516-
"""
517-
# 1) Build instruction
518-
instr = prompt or f"Translate the following text into {target_language}, preserving formatting."
519-
520-
# 2) Build JSON payload for clarity and structure
521-
payload = {
522-
"instruction": instr,
523-
"text": text
524-
}
525-
full_prompt = json.dumps(payload, indent=2, ensure_ascii=False)
506+
Translate arbitrary text into the target language, preserving formatting
507+
and placeholders. Returns only the translated text—no explanations.
508+
"""
509+
# 1) Build a strict instruction
510+
instr = prompt or (
511+
f"Translate the following text into {target_language}. "
512+
"Output ONLY the translated text, preserving orignal formatting, "
513+
"indentation, and placeholder tokens (e.g. {field}). "
514+
"Do NOT include any explanations or comments."
515+
)
516+
517+
# 2) Combine instruction and text
518+
full_prompt = instr + "\n\n" + text
526519

527520
# 3) Record prompt & token count
528521
self.last_prompt = full_prompt
@@ -534,14 +527,20 @@ def translate(
534527
deterministic=deterministic,
535528
temperature=temperature,
536529
max_tokens=max_tokens
537-
)
530+
) or ""
538531

539532
# 5) Record response & its token count
540533
self.last_response = result
541534
self.last_response_token_count = self._count_tokens(result)
542535

543536
return result
544537

538+
def _str_presenter(dumper, data):
539+
# if the string has a newline, use block style;
540+
# otherwise fall back to the default
541+
style = '|' if '\n' in data else None
542+
return dumper.represent_scalar('tag:yaml.org,2002:str', data, style=style)
543+
545544
def translate_config(
546545
self,
547546
target_language: str,
@@ -559,21 +558,20 @@ def translate_config(
559558
- any stimuli entries where type is 'text' or 'textbox'
560559
561560
If `config` is:
562-
• a file path (str) → load via load_config()
561+
• a file path (str) → loaded via load_config()
563562
• a dict returned from load_config()
564-
• None → will look for "./config/config.yaml" by default
563+
• None → defaults to "./config/config.yaml"
565564
566565
If `output_dir` is provided, writes out a translated YAML:
567-
• filename is `output_name` if given, else
568-
original basename + ".translated.yaml".
566+
filename is `output_name` if given, else original basename + ".translated.yaml".
569567
570-
Returns the final raw YAML dict.
568+
Returns the updated raw YAML dict.
571569
"""
572570
# 1) Determine config source
573571
if config is None:
574572
default_path = os.path.join(os.getcwd(), "config", "config.yaml")
575573
if not os.path.exists(default_path):
576-
raise FileNotFoundError(f"No config given and default not found at {default_path}")
574+
raise FileNotFoundError(f"No config found at {default_path}")
577575
config = default_path
578576

579577
# 2) Load or unwrap structured config
@@ -595,7 +593,10 @@ def translate_config(
595593
mapping[key] = self.translate(
596594
text=val,
597595
target_language=target_language,
598-
prompt=prompt or f"Translate this label into {target_language}:",
596+
prompt=prompt or (
597+
f"Translate this label into {target_language}. "
598+
"Output ONLY the translated text, preserving original format. No trailing newline"
599+
),
599600
deterministic=deterministic,
600601
temperature=temperature,
601602
max_tokens=max_tokens
@@ -604,29 +605,67 @@ def translate_config(
604605
# 4) Translate stimuli text fields
605606
stim_config = structured['stim_config']
606607
for name, spec in stim_config.items():
607-
stype = spec.get('type')
608-
if stype in ('text', 'textbox') and 'text' in spec:
608+
if spec.get('type') in ('text', 'textbox') and 'text' in spec:
609609
original = spec['text']
610610
if isinstance(original, str) and original.strip():
611-
translated = self.translate(
611+
raw_yaml['stimuli'][name]['text'] = self.translate(
612612
text=original,
613613
target_language=target_language,
614-
prompt=prompt or f"Translate this stimulus text into {target_language}:",
614+
prompt=prompt or (
615+
f"Translate this stimulus text into {target_language}. "
616+
"Output ONLY the translated text, preserving original format. No trailing newline"
617+
),
615618
deterministic=deterministic,
616619
temperature=temperature,
617620
max_tokens=max_tokens
618621
)
619-
raw_yaml['stimuli'][name]['text'] = translated
620622

621623
# 5) Optionally write translated YAML to disk
622624
if output_dir:
623625
os.makedirs(output_dir, exist_ok=True)
624626
filename = output_name or f"{original_name}.translated.yaml"
625627
out_path = os.path.join(output_dir, filename)
628+
629+
LLMDumper = type("LLMDumper", (yaml.SafeDumper,), {})
630+
def _str_presenter(dumper, data):
631+
style = '|' if '\n' in data else None
632+
return dumper.represent_scalar(
633+
'tag:yaml.org,2002:str',
634+
data,
635+
style=style
636+
)
637+
LLMDumper.add_representer(str, _str_presenter)
638+
def _list_presenter(dumper, data):
639+
# inline only for lists of scalars length ≤ 10
640+
if (
641+
len(data) <= 10 and
642+
all(not isinstance(x, (dict, list)) for x in data)
643+
):
644+
flow = True
645+
else:
646+
flow = False
647+
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=flow)
648+
LLMDumper.add_representer(list, _list_presenter)
649+
650+
# Monkey‐patch it onto SafeDumper:
651+
yaml.SafeDumper.add_representer(str, _str_presenter)
626652
with open(out_path, 'w', encoding='utf-8') as f:
627-
yaml.safe_dump(raw_yaml, f, allow_unicode=True)
653+
yaml.dump(raw_yaml, f, allow_unicode=True,sort_keys=False, Dumper=LLMDumper)
654+
655+
task_keys = ['window', 'task', 'timing']
656+
structured_config = {
657+
'raw': raw_yaml,
658+
'task_config': {k: v for key in task_keys for k, v in raw_yaml.get(key, {}).items()},
659+
'stim_config': raw_yaml.get('stimuli', {}),
660+
'subform_config': {
661+
'subinfo_fields': raw_yaml.get('subinfo_fields', []),
662+
'subinfo_mapping': raw_yaml.get('subinfo_mapping', {}),
663+
},
664+
'trigger_config': raw_yaml.get('triggers', {}),
665+
'controller_config': raw_yaml.get('controller', {}),
666+
}
628667

629-
return raw_yaml
668+
return structured_config
630669

631670

632671
def doc2task(

psyflow/templates/task2doc_prompt.txt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ Extract the full task name and use it as the document’s top-level heading.
3434
- PsyFlow Version
3535
- PsychoPy Version
3636

37-
## 2. Task Overview
37+
## 1. Task Overview
3838
• One concise paragraph summarizing the goal and design of the task.
3939

40-
## 3. Task Flow
40+
## 2. Task Flow
4141
Split into two subtables—**Block-Level Flow** and **Trial-Level Flow**—plus **Controller Logic** and **other logics** if `util.py` exists.
4242
• Block-Level Flow comes from `main.py`.
4343
• Trial-Level Flow comes from `run_trial.py`.
@@ -47,7 +47,7 @@ Split into two subtables—**Block-Level Flow** and **Trial-Level Flow**—plus
4747
Leave a blank line after each table before continuing.
4848
Each description must be detailed enough that an LLM could reconstruct the code. Incorporate any timing parameters or stimuli defined in `config.yaml` into the descriptions.
4949

50-
## 4. Configuration Summary
50+
## 3. Configuration Summary
5151
Note that all settings live in `config/config.yaml`.
5252
In this section, summarize each subsection in its own table (with header, separator, blank line):
5353

@@ -73,9 +73,9 @@ Example:
7373

7474
**f. Adaptive Controller (if exists)** — a table with columns: Parameter, Value
7575

76-
## 5. Methods (for academic publication)
76+
## 4. Methods (for academic publication)
7777
Write this like a Methods section in a paper, with academic tone and clarity.
78-
Prepare one or two paragraph containing the following:
78+
Prepare two or three paragraphs containing the following:
7979
- What participants see at each step
8080
- Any adaptive algorithms or parameters
8181
- The rationale behind major design choices

psyflow/utils.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,47 @@ def initialize_exp(settings, screen_id: int = 1) -> Tuple[Window, keyboard.Keybo
162162
logging.console.setLevel(logging.INFO)
163163

164164
return win, kb
165+
166+
167+
168+
import asyncio
169+
from edge_tts import VoicesManager
170+
from typing import Optional
171+
async def _list_supported_voices_async(filter_lang: Optional[str] = None):
172+
vm = await VoicesManager.create()
173+
voices = vm.voices
174+
if filter_lang:
175+
voices = [v for v in voices if v["Locale"].startswith(filter_lang)]
176+
return voices
177+
def list_supported_voices(
178+
filter_lang: Optional[str] = None,
179+
human_readable: bool = False
180+
):
181+
"""
182+
– Returns raw voice dicts if human_readable=False.
183+
– Prints a formatted table (including VoicePersonalities) if human_readable=True.
184+
"""
185+
voices = asyncio.run(_list_supported_voices_async(filter_lang))
186+
if not human_readable:
187+
return voices
188+
189+
# Table header including the Personalities column
190+
header = (
191+
f"{'ShortName':25} {'Locale':10} {'Gender':8} "
192+
f"{'Personalities':30} {'FriendlyName'}"
193+
)
194+
separator = "-" * len(header)
195+
print(header)
196+
print(separator)
197+
198+
for v in voices:
199+
short = v.get("ShortName", "")[:25]
200+
loc = v.get("Locale", "")[:10]
201+
gen = v.get("Gender", "")[:8]
202+
# Extract the personalities list and join with commas
203+
pers_list = v.get("VoiceTag", {}).get("VoicePersonalities", [])
204+
pers = ", ".join(pers_list)[:30]
205+
# Use FriendlyName as the display name
206+
disp = v.get("FriendlyName", v.get("Name", ""))
207+
208+
print(f"{short:25} {loc:10} {gen:8} {pers:30} {disp}")

0 commit comments

Comments
 (0)