ReMe/reme/steps/file_io/_file_io.py
xyf2020 5bc46c88b6
feat(benchmark): enhance session memory retrieval and isolate benchmark assets (#409)
* chore(benchmark): isolate dataset/workspaces/results per benchmark

- Move shared benchmark/{datasets,memory_workspaces,results} into per-benchmark subdirs benchmark/<name>/{dataset,workspaces,results}
- Update beam/longmemeval config.yaml and run.py path defaults
- Relocate longmemeval download.py to benchmark/longmemeval/ (downloads into dataset/ subdir); inline dataset download docs into README
- Update .gitignore: benchmark/*/{dataset,workspaces,results}/
- Move result-{beam,longmemeval}.md to benchmark/results_md/ and drop result- prefix; update README links
- Fix stale path refs in llm_judge.py and logs/demo_search_format.py

* feat(benchmark): add read tool to agentic answer and update BEAM results

- Add 'read' to job_tools in BaseAgenticAnswerStep for file reading capability
- Document read tool usage in lme/agentic_answer.yaml system prompt
- Update result-beam.md with latest evaluation scores (OVERALL: 0.623/0.580)

* feat(auto_memory): add source line-number markers for note traceability

- Add _format_history hook in AutoMemoryStep with line-number annotation
- Override in BeamAutoMemoryStep to prefix each turn with [Ln] for citation
- Add session_file variable to prompt templates for source marker paths
- Simplify repeated extraction rules by referencing system prompt
- Enhance agentic_answer search strategy (multi-search, read tool hint)
- Add warning log on ReadStep failure

* feat(beam): enhance auto_memory with source markers and pilot ingest tooling

* refactor(beam): rename max_chunk_words to max_segment_words, drop one-off pilot scripts

* feat: add CompressorStep and search_v2 dual-mode session compression

- Add CompressorStep (reme/steps/evolve/compressor.py) for direct LLM
  text compression with optional query-guided relevance filtering
- Extend search_v2_step to support query-aware and query-independent
  session transcript compression via _compress injected kwargs
- Refactor _source_format.py: split into render_chunk_entries +
  join_chunk_entries; session chunks now render line-aligned with
  L<n>: prefixes for verbatim/compressed parity
- Add JOB_TOOLS and INJECTED_JOB_KWARGS to BaseAgenticAnswerStep for
  per-subclass tool and parameter injection
- LmeAgenticAnswerStep injects _search._compress payload to enable
  query-aware compression during benchmark evaluation
- Record compression ablation results in result-longmemeval.md
- Add unit tests for CompressorStep and search compression paths

* refactor(compress): relax session compression to lenient format-preserving strategy and update LME results

* refactor(benchmark): make session compression config-driven via compress_session flag

Move session-transcript compression from LME hard-coded injection to a
runtime context flag set by evaluation.compress_session in each
benchmark config. Compression is off by default for both BEAM and LME,
and BaseAgenticAnswerStep now conditionally injects the _search compress
payload only when the flag is truthy.

* feat(lme/auto_memory): add source attribution markers with line numbers

Add _format_history to annotate each turn with [Ln] line numbers and
expose {session_file} in prompts so the agent can emit bare wikilink-style
source markers like [[session/dialog/s1.jsonl#L1-L2,L5-L6]] at the end
of factual entries. Consolidate the per-prompt body/format rules into
references to the system prompt to avoid drift, and add frontmatter-
protection guidance for the edit tool.

* feat: improve agentic answer prompt and update beam 100K results

- Strengthen abstention rule: prohibit extrapolation from related but
  non-direct evidence
- Add multi-angle search after preliminary answer to check for
  conflicting/supplementary/updated information
- Add max-iteration fallback to 'Information not found'
- Update beam.md with 100K results (agentscope 2.0.4.post1, from scratch)
  including per-type token consumption and memory construction stats
- config.yaml: 100K dataset, 20 workers for BEAM evaluation
- run.py: add memory construction token usage tracking (default agent)
- Overall: 0.635 → 0.654 (+0.019), contradiction_resolution: 0.338 → 0.478
  (+0.140), abstention: 0.500 → 0.525 (+0.025)

* feat(read): add session-aware formatting for read tool and update BEAM eval

- Add truncate_session_output in _file_io.py to render jsonl session
  lines as [speaker @ time] content before byte-budget truncation
- Add read_step_format_session flag to ReadStep, honoring injected
  job kwargs (precedence) and YAML fallback
- Inject read_step_format_session=True into BaseAgenticAnswerStep
  so agentic answer reads render session transcripts human-readably
- Refine BEAM agentic_answer prompt: continue multi-angle search
  after preliminary answer, forbid fabrication/extrapolation
- Update BEAM config to 1M variant and add sequential 100K-eval /
  1M-build shell script
- Refresh benchmark/results_md/beam.md with latest results

* chore(config): disable expand_links in beam and lme search_v2 configs

* refactor(beam): drop one-off sequential 100K-eval-then-1M-build script

* fix(benchmark): add compressor job to beam config and fix BEAM clone instructions

- Add compressor job and compressor as_llm component to reme/config/beam.yaml
  (aligned with lme.yaml) so that compress_session: true works for BEAM
- Add graceful degradation guard in search_v2._compress_session_entries:
  when the compressor job is missing from the active config, log a warning
  and skip compression instead of raising 'Job compressor not found'.
  Skipped when there is no app_context so unit tests mocking run_job still
  drive compression behavior.
- Fix BEAM download instructions in README.md/README_ZH.md: add mkdir -p
  before cd benchmark/beam/dataset (the directory is gitignored and absent
  in a fresh clone)

* fix(steps): guard compressor exceptions and fix ReadStep boolean override

1. search_v2: catch per-entry exceptions from run_job('compressor') inside
   compress() so asyncio.gather never propagates a compressor failure (e.g.
   temporary LLM outage). The failing entry keeps its original body while
   remaining entries are still compressed, preserving already-retrieved
   search results.

2. read: replace 'context_value or yaml_value' with an existence check so
   that a runtime-injected False can explicitly disable a YAML-true
   read_step_format_session flag.

Add focused unit tests for both paths.

* fix(search_v2): use existence check for strict_date_filter boolean override

Replace 'context_value or yaml_value' with an existence-based check so
that a runtime-injected False can explicitly disable a YAML-true
strict_date_filter flag, consistent with the read_step_format_session fix.

* refactor(search): simplify strict_date_filter fallback to truthiness-or

* style(test): rename unused param to satisfy pylint W0613

---------

Co-authored-by: sa-buc <jiangniurou.xyf@dail-algo011164204033.ET135>
2026-08-05 19:23:42 +08:00

258 lines
8.6 KiB
Python

"""Encoding-aware file IO, output truncation, and per-path write locks."""
import asyncio
from pathlib import Path
from typing import Iterable
import aiofiles
import aiofiles.os
from ...constants import DEFAULT_MAX_BYTES, MAX_FILE_READ_BYTES, TRUNCATION_NOTICE_MARKER
from ...schema import FileChunk
from ...utils import get_logger
logger = get_logger(log_to_file=False)
# ---------------------------------------------------------------------------
# In-process per-path write lock.
# ---------------------------------------------------------------------------
_PATH_LOCKS_MAX = 1024
_PATH_LOCKS: dict[str, asyncio.Lock] = {}
_PATH_LOCKS_REGISTRY = asyncio.Lock()
async def get_path_lock(target: Path) -> asyncio.Lock:
"""Return the asyncio.Lock for ``target``; created lazily on first request."""
key = str(target)
async with _PATH_LOCKS_REGISTRY:
lock = _PATH_LOCKS.get(key)
if lock is None:
if len(_PATH_LOCKS) >= _PATH_LOCKS_MAX:
to_remove = [k for k, v in _PATH_LOCKS.items() if not v.locked()]
for k in to_remove[: len(_PATH_LOCKS) // 2]:
del _PATH_LOCKS[k]
lock = asyncio.Lock()
_PATH_LOCKS[key] = lock
return lock
# ---------------------------------------------------------------------------
# Encoding detection
# ---------------------------------------------------------------------------
_STANDARD_TEXT_EXTS = {
".md",
".py",
".js",
".ts",
".json",
".yaml",
".yml",
".html",
".css",
".xml",
".log",
".conf",
".ini",
".txt",
".sh",
}
_NON_STANDARD_EXTS = {".csv", ".bat", ".cmd", ".reg"}
def _try_decode(data: bytes, encodings: Iterable[str]) -> tuple[str, str] | None:
"""Return ``(text, encoding)`` for the first encoding that decodes ``data`` cleanly."""
for enc in encodings:
try:
return data.decode(enc), enc
except (UnicodeDecodeError, LookupError):
continue
return None
def _decode_known_file(data: bytes, file_extension: str) -> tuple[str, str]:
"""Decode file bytes using the extension as a hint. Returns ``(text, encoding)``."""
if data.startswith(b"\xef\xbb\xbf"):
return data.decode("utf-8-sig"), "utf-8-sig"
if data.startswith((b"\xff\xfe", b"\xfe\xff")):
try:
return data.decode("utf-16"), "utf-16"
except UnicodeDecodeError:
pass
ext = (file_extension or "").lower()
if ext in _STANDARD_TEXT_EXTS:
try:
return data.decode("utf-8-sig"), "utf-8"
except UnicodeDecodeError:
pass
if ext in _NON_STANDARD_EXTS:
result = _try_decode(data, ("utf-8-sig", "gbk"))
if result is not None:
text, enc = result
return text, "utf-8" if enc == "utf-8-sig" else enc
return data.decode("utf-8", errors="replace"), "utf-8"
# ---------------------------------------------------------------------------
# File read / write
# ---------------------------------------------------------------------------
async def read_file_safe(file_path, max_bytes: int = MAX_FILE_READ_BYTES) -> tuple[str, str]:
"""Read file in byte mode and decode using extension-aware strategy.
Returns ``(text, encoding)``.
"""
stat = await aiofiles.os.stat(str(file_path))
read_size = min(stat.st_size, max_bytes)
async with aiofiles.open(str(file_path), "rb") as f:
data = await f.read(read_size)
return _decode_known_file(data, Path(file_path).suffix)
async def read_file_lines_safe(
file_path,
start_line: int,
end_line: int | None,
*,
max_collect_bytes: int = DEFAULT_MAX_BYTES * 2,
) -> tuple[str, int, str]:
"""Read a 1-based inclusive line range without loading the full file.
Returns ``(text, total_lines, encoding)``.
"""
encoding = await detect_file_encoding(file_path)
lines: list[str] = []
collected_bytes = 0
total = 0
async with aiofiles.open(str(file_path), "r", encoding=encoding, errors="replace") as f:
async for line in f:
total += 1
if total >= start_line and (end_line is None or total <= end_line):
if collected_bytes < max_collect_bytes:
cleaned = line.rstrip("\n")
lines.append(cleaned)
collected_bytes += len(cleaned.encode(encoding, errors="replace")) + 1
return "\n".join(lines), total, encoding
async def detect_file_encoding(file_path, sniff_bytes: int = 8192) -> str:
"""Detect the encoding of an existing file so writes can preserve it."""
try:
async with aiofiles.open(str(file_path), "rb") as f:
data = await f.read(sniff_bytes)
except Exception:
return "utf-8"
_, enc = _decode_known_file(data, Path(file_path).suffix)
return enc
async def write_file_safe(file_path: Path, content: str | bytes, encoding: str = "utf-8") -> None:
"""Write ``content`` to ``file_path`` in binary mode; creates parent dirs."""
file_path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(content, str):
try:
payload = content.encode(encoding)
except (UnicodeEncodeError, LookupError):
logger.warning(
"write_file_safe: %r cannot encode all chars, falling back to utf-8",
encoding,
)
payload = content.encode("utf-8")
else:
payload = content
async with aiofiles.open(str(file_path), "wb") as f:
await f.write(payload)
# ---------------------------------------------------------------------------
# Output truncation
# ---------------------------------------------------------------------------
def truncate_text_output(
text: str,
*,
start_line: int = 1,
total_lines: int = 0,
max_bytes: int = DEFAULT_MAX_BYTES,
file_path: str | None = None,
encoding: str = "utf-8",
) -> str:
"""Truncate text by bytes preserving line integrity; append a continuation notice."""
if not text or max_bytes <= 0:
return text
try:
text_bytes = text.encode(encoding)
if len(text_bytes) <= max_bytes:
return text
truncated = text_bytes[:max_bytes]
result = truncated.decode(encoding, errors="ignore")
newline_count = result.count("\n")
next_line = start_line + max(1, newline_count)
if next_line <= total_lines:
read_from = next_line
elif start_line < total_lines:
read_from = total_lines
else:
return result
notice = (
TRUNCATION_NOTICE_MARKER + f"\nThe output above was truncated."
f"\nThe full content is saved to the file and contains {total_lines} lines in total."
f"\nThis excerpt starts at line {start_line} and covers the next {max_bytes} bytes."
f"\nIf the current content is not enough, call `read` with file={file_path or ''} "
f"start_line={read_from} to read more."
)
return result + notice
except Exception:
logger.warning("truncate_text_output failed, returning original text", exc_info=True)
return text
def truncate_session_output(
text: str,
*,
start_line: int = 1,
total_lines: int = 0,
max_bytes: int = DEFAULT_MAX_BYTES,
file_path: str | None = None,
encoding: str = "utf-8",
) -> str:
"""Render session jsonl lines then truncate by bytes preserving line integrity.
Each line of *text* is expected to be a serialized ``Msg`` JSON from a raw
session transcript (``*.jsonl``). Lines are first rendered via
:func:`~reme.steps.index._source_format.render_session_chunk_lines` — one
message per line as ``[speaker @ created_at] content`` with internal
newlines flattened — and then truncated with the same byte-budget logic
as :func:`truncate_text_output`.
"""
if not text or max_bytes <= 0:
return text
# Render each jsonl line into a human-readable [speaker @ time] content line.
# Import locally to avoid a hard dependency from file_io on the index package
# at module load time; the import is safe (no circular dependency).
from ..index._source_format import render_session_chunk_lines
chunk = FileChunk(text=text)
rendered_lines = render_session_chunk_lines(chunk)
rendered_text = "\n".join(rendered_lines)
# Reuse the same byte-budget truncation as truncate_text_output.
return truncate_text_output(
rendered_text,
start_line=start_line,
total_lines=total_lines,
max_bytes=max_bytes,
file_path=file_path,
encoding=encoding,
)