mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-10 22:41:06 +00:00
### 1. Agent Wrapper(统一 Agent 后端抽象) - **`base_agent_wrapper.py`**:`reply()` 返回值从 `tuple[str, Any]` 改为 `dict`(含 `session_id` / `last_message` / `result` / 可选 `structured_output`);`reply_stream()` 改为产出统一的 `StreamChunk`。废弃 `add_tools()`,改为 `add_job_tools(names: list[str])`(按名解析 BaseJob)与 `add_skills()`;新增 `_resolve_job_tools()`、`_merged_kwargs()`、`_chunk()` 辅助方法及 `project_path` / `project_skills_root` 属性。 - **`as_agent_wrapper.py`(AgentScope 后端)**: - 会话持久化重写:`session_path` 落地到 `<vault>/<session_dir>/agentscope/`,`_load_state` 支持 `resume` / `session_id` / `fork_session`,并做 UUID 校验(`_validate_session_id`);`_cleanup_expired_sessions` 按天数清理过期会话。 - 新增内置工具集(`BypassAnalysisBash` + Edit/Glob/Grep/Read/Write),`BypassAnalysisBash` 绕过 AgentScope 自带 Bash 静态分析以让 permission_mode 生效;`_resolve_skills()` 把配置的 skill 暴露给后端,`_load_tool_env()` 注入项目 `.env`。 - `_event_to_chunk()` 把 20+ 种 AgentScope 事件(Reply/Text/Thinking/Data/ToolCall/ToolResult/ModelCall/ExceedMaxIters)归一化为 `StreamChunk`。 - **`cc_agent_wrapper.py`(Claude Code SDK 后端,+551 行)**: - 新增 `_CcFileSessionStore`:基于 vault 的文件型会话存储,实现 append(按 uuid 去重)/ load / list / delete / list_subkeys,并对路径做 `_safe_parts` + `resolve()` 防越界校验。 - `_build_options()`:统一构建 `ClaudeAgentOptions`,处理 skills、disallowed_tools(默认禁 `WebSearch`)、`.env` 注入、Claude Code 的 API 凭据解析(`_claude_code_api_env`,多级 base_url/api_key 回退)、`CLAUDE_CONFIG_DIR` 设置、skill 目录软链接(`_ensure_claude_skill_dir`)。 - `_raw_event_to_chunk()` / `_message_content_to_chunks()`:把 Anthropic 流式事件(message_start/delta/stop、content_block_*)与 SDK 消息块(AssistantMessage/UserMessage/ResultMessage/RateLimitEvent)转换为统一 `StreamChunk`;跟踪 block_id/block_type/tool_call_name 做关联;处理尾部 `"success"` 误报异常的吞掉逻辑。 ### 2. 统一流式协议(StreamChunk / ChunkEnum) - **`stream_chunk.py`**:`StreamChunk` 扩展为承载 AS + CC 双后端完整信息的统一结构,新增 `session_id` / `block_id` / `tool_call_id` / `tool_call_name` / `media_type` / `input_tokens` / `output_tokens` 等字段,纯文本流仍保持轻量。 - **`chunk_enum.py`**:补全生命周期标记 `REPLY_START` / `REPLY_END`,并文档化两套后端事件 → ChunkEnum 的映射。 ### 3. Index 模块重构(变化批次化 + dispatch) - 新增 `_change_batch.py`:`coalesce_changes()` 把同路径多次事件折叠为最终状态(结合 path 存在性判定),`bucket_changes()` 按 watchfiles.Change 分桶。 - 新增 `init_changes.py`(`InitChangesStep`):一次性扫描,对比 file_store / file_catalog 已索引节点计算 added/modified/deleted,写入 `context["changes"]` 后 dispatch。 - 新增 `update_changes.py`:抽象基类 `ChangeApplyStep` 统一 added/modified/deleted 处理与错误收集;`UpdateCatalogStep`(写 file_catalog)、`UpdateIndexStep`(写 file_store,含按后缀解析 chunker)。 - **`watch_changes.py`**:改用 `dispatch_step_specs`(基类提供的 `dispatch_steps()`),每批先 `coalesce_changes` 再 dispatch;默认参数调整(debounce 5000ms / step 1000ms / poll 5000ms)并暴露常量。 - 删除旧步骤:`clear_and_scan` / `foreach_dispatch` / `scan_changes` / `update_catalog`(旧) / `update_index`(旧);`clear_store.py` 取代 clear_and_scan。 ### 4. Evolve / Dream 模块(拆分为多步 pipeline) - 删除旧的单体 `auto_dream.py` / `dream.py` / `dream.yaml`,新增 `dream/` 子包,按 5 个步骤组织: - **`extract.py`**:扫描当日 day-index + daily 笔记,对比 file_catalog 找出 changed/deleted,调用 LLM 全局抽取 `units`(procedure/personal/wiki 三桶)与 `topics`,路径与桶做清洗/路由。 - **`integrate.py`**:逐个 unit 调用 LLM 写入 digest,结构化输出 `IntegrateOutcome`(CREATE/CORROBORATE/REFINE/CORRECT),失败 unit/路径收集回写。 - **`topics.py`**:写 `daily/<date>/interests.yaml`,结合当天已有 + 近 N 天做去重(`normalize_topic`),可走 LLM 或纯规则去重两条路径。 - **`proactive.py`**:读取当日 `interests.yaml`,作为主动推荐话题的入口。 - **`finish.py`**:把变更路径落盘到 dream file_catalog(checkpoint),渲染最终汇总摘要。 - 新增 `schema.py`(`DreamState` 等跨步骤共享状态与结构化输出模型)与 `utils.py`(状态存取、扫描打包、YAML 读写、结构化回复解析等公共函数)。 - `evolve/__init__.py` 导出全部新 step。 ### 5. auto_memory / auto_resource(适配新 Agent API) - **`auto_memory.py`**:会话路径迁移到 `<session_dir>/dialog/<session_id>.jsonl`;改用 `job_tools`;新增 `source_conversation` frontmatter 反向链接(`_session_link`);执行后刷新 day 索引(`refresh_day_index`),并对 session_id 做合法性校验。 - **`auto_resource.py`**:资源改用「同名 daily note」方案(`_compute_note_stem` 取文件 stem);批量处理 `changes: list[dict]`(`_handle_change` 逐项处理,返回逐项结果摘要);agent 会话 id 用稳定的 `uuid5`;同样刷新 day 索引。 ### 6. BaseStep 基类增强 - 新增 `dispatch_steps` / `dispatch_step_specs` 机制:`_resolve_dispatch_step()` 支持字符串或 dict 形式的 step spec,`dispatch_steps()` 复用当前 context 调用下游 step。 - 新增 `config_value()`:按 key 取 app config,缺失时回退 `ApplicationConfig` 默认值。 - 小幅清理:`language` 初始化、`copy()`、`Ref.__init__` 签名精简。 ### 7. Components 改动 - **`file_store/local_file_store.py`**:持久化改用 zstd 压缩(`.jsonl.zst`,通过新 `utils/jsonl_zst.py`);upsert 时先删除旧 chunk 的 keyword 文档;embedding 复用改为 `(text, embedding)` 键控,要求文本一致才复用;新增 `_matches_search_filter()` 对 vector/keyword 搜索做 path/path_prefix/metadata 的统一后过滤。 - **`keyword_index/bm25_index.py`**:索引文件名加入组件名 + tokenizer 指纹(sha256 前 12 位),快照/恢复时校验指纹防配置漂移;空索引 dump 时删除文件,加载失败抛错而非静默。 - **`file_chunker/markdown_file_chunker.py`**:弃用 `python-frontmatter`,改用内置 YAML 解析(非法 YAML 不阻断正文索引),并修正因 frontmatter 占用行号导致的 AST 行号偏移(`line_offset`)。 - **`cron_job.py`**:大幅简化(-187 行),由原来「dispatch 外部 job/step + 多种调度模式」改为「在自身 steps 上跑 cron 表达式」;`Application` 启动顺序随之调整为 base > stream > background > cron。 - 其余小调整:service(base/http/mcp)、file_graph、file_catalog、as_llm、as_embedding、tokenizer、prompt_handler、base_component 的签名/接口微调。 ### 8. Application 生命周期 - `_start()` 启动顺序明确为 components → base → stream → background → cron,启动失败会触发 `_close()` 回滚并 re-raise(不再吞异常)。 - 启动时创建 `session_dir` 目录;新增 `update_component()`(按类型/名就地更新已存在组件,不存在则报错)。 ### 9. File IO / 路径安全 - **`_path.py`**:`resolve_path` 增加 vault 越界防护(`is_relative_to` 校验),禁止 `.` / `..` 路径分量,支持 `allow_empty`。 - **`read.py`**:大文件(超过 `MAX_FILE_READ_BYTES`)走按行读取 `read_file_lines_safe`,避免一次性载入内存。 - **`_file_io.py` / `_daily_index.py` / `_path.py`** 等支持函数补齐(如 `refresh_day_index`、`read_file_lines_safe`)。 - **`env_utils.py`**:新增 `parse_env_file()`,`load_env()` 返回加载到的键值、支持 `override`、对无路径调用做幂等缓存。 ### 10. Config - `ApplicationConfig` 新增 `session_dir`(默认 `reme_session`)。 - `config_parser.py`:环境变量展开后做类型转换(`_convert_value`)、dot-notation 与 key=value 参数校验更严格、配置文件路径支持相对 `_CONFIG_DIR` 查找、根非 dict 报错。 - `default.yaml`:作业编排改用 `init_changes_step` + `dispatch_steps`(index/resource/digest 三个 watch loop 与 reindex);新增 `auto_dream`(4 步)、`proactive` 作业,移除旧 `dream`;file_catalog 增配 `resource` / `digest` / `dream` 实例;LLM 默认值与 Claude Code 凭据配置调整(tool_result_limit 50000、thinking_enable=false 等)。 ### 11. 其它 - 新增 `steps/common/add.py`(`AddStep` 算术 demo)、`channel/__init__.py` 与 common `__init__` 导出整理。 - 新增 4 篇文档:`docs4/auto_dream_logic_and_step_refactor.md`、`docs4/watch_loop_step_refactor_plan.md`、`docs4/todo.md`,以及 `reme_design.md` 更新。 **
452 lines
17 KiB
Python
452 lines
17 KiB
Python
"""``ingest`` — capture an externally-received asset into ``resource/<date>/``.
|
||
|
||
This step is the dedicated information-capture interface: when an
|
||
external channel (wechat group, email, browser save, API push, ...)
|
||
hands the agent a file, ``ingest`` lands it in ``resource/<YYYY-MM-DD>/``
|
||
keyed by the day it was received (always today, local time),
|
||
alongside a ``meta.json`` row recording provenance.
|
||
|
||
This is the only ingest path through ``resource/``. Materials the
|
||
main agent *actively* fetches or generates during a daily task
|
||
belong inside the daily folder as sibling materials, not here.
|
||
|
||
For generic file copy (local fs → arbitrary vault path) use
|
||
``upload`` instead.
|
||
|
||
Bucket layout::
|
||
|
||
resource/<YYYY-MM-DD>/
|
||
meta.json # JSON array of FileNode rows
|
||
<date>.md # derived markdown view
|
||
<channel>__<HHMMSS>__<source-basename> # the asset itself
|
||
...
|
||
|
||
Naming convention — the file name is **derived**, never caller-supplied::
|
||
|
||
<channel>__<HHMMSS>__<source-basename>
|
||
|
||
* ``<channel>`` — top-level identity (anchors provenance), lowercase
|
||
letters/digits/dashes only.
|
||
* ``<HHMMSS>`` — receive time within the bucket day (the date is
|
||
already implicit in the bucket folder).
|
||
* ``<source-basename>`` — the basename of the ``path`` argument,
|
||
after rejecting path separators, dot segments, and leading dots.
|
||
|
||
This format is self-describing in directory listings + wikilinks
|
||
(``[[resource/<date>/wechat__153022__report.pdf]]``) and makes
|
||
cross-channel basename collisions structurally impossible. Two
|
||
genuine duplicates (same channel + same second + same basename)
|
||
are reported as an error — the step never silently dedupes, so
|
||
callers see the conflict and can decide whether to retry, rename
|
||
upstream, or skip.
|
||
|
||
Each ``ingest`` call:
|
||
|
||
1. Resolves the bucket date as today (local time).
|
||
2. Validates the inputs: ``path`` exists, ``channel`` matches the
|
||
allowed character class, ``description`` is non-empty, the
|
||
source basename has no path separators / dot segments / leading
|
||
dot.
|
||
3. Builds the final name from the format above.
|
||
4. Under a per-day file lock, checks the final name against
|
||
``meta.json`` ∪ the on-disk listing. Any collision → error
|
||
(no silent suffixing).
|
||
5. Copies the asset into the bucket.
|
||
6. Appends a :class:`FileNode` row to ``meta.json`` (with provenance
|
||
on ``front_matter``) and re-renders ``<date>.md``.
|
||
|
||
Parameters:
|
||
|
||
* ``path`` (required) — local filesystem path to the asset to ingest.
|
||
* ``channel`` (required) — inbound channel identifier (wechat /
|
||
email / browser / api / ...). Lowercase letters / digits / dashes
|
||
only.
|
||
* ``description`` (required) — analysis hint for downstream agents:
|
||
where the asset came from, what kind of content it carries, and how
|
||
it should be interpreted. The dreamer / auto_memory reads this
|
||
verbatim from ``meta.json`` to decide how to read the asset (skim
|
||
vs. deep parse, structured extraction vs. summarization, etc.), so
|
||
callers should write enough detail to drive that decision — not
|
||
just a title. Multi-line is fine; the ``<date>.md`` bullet view
|
||
flattens for display while ``meta.json`` preserves the original.
|
||
* ``metadata`` (optional dict) — extras persisted on the meta row.
|
||
``source`` (free-form origin within the channel) is conventional;
|
||
any other keys pass through verbatim. Keys ``name``, ``channel``,
|
||
``received_at``, ``description`` are reserved.
|
||
|
||
Returns ``{date, name, path}`` on success or ``{error}`` on failure.
|
||
"""
|
||
|
||
import datetime
|
||
import fcntl
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
from ..base_step import BaseStep
|
||
|
||
from ...components import R
|
||
|
||
from ...schema import FileFrontMatter, FileNode
|
||
|
||
|
||
# Channel identifier character class — keeps the derived filename predictable
|
||
# and parseable (the `__` separator is also disjoint from this set).
|
||
_CHANNEL_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
||
|
||
# Keys the step manages itself — callers cannot smuggle them in via metadata.
|
||
_RESERVED_METADATA_KEYS = frozenset({"name", "channel", "received_at", "description"})
|
||
|
||
|
||
@R.register("ingest_step")
|
||
class IngestStep(BaseStep):
|
||
"""Capture an external asset into ``resource/<date>/`` and update the day's meta + index."""
|
||
|
||
async def execute(self):
|
||
assert self.context is not None
|
||
path: str = (self.context.get("path", "") or "").strip()
|
||
channel: str = (self.context.get("channel", "") or "").strip()
|
||
description: str = (self.context.get("description", "") or "").strip()
|
||
metadata_raw = self.context.get("metadata") or {}
|
||
|
||
prepared, prep_error = _prepare_inputs(path, channel, description, metadata_raw)
|
||
if prep_error:
|
||
self._fail({"error": prep_error})
|
||
self.logger.info(f"[{self.name}] ingest failed channel={channel!r} error={prep_error!r}")
|
||
return
|
||
|
||
try:
|
||
outcome = self._land(
|
||
src=prepared["src"],
|
||
date=prepared["date"],
|
||
final_name=prepared["final_name"],
|
||
entry_fields={
|
||
**prepared["metadata"],
|
||
"channel": channel,
|
||
"received_at": prepared["received_at"],
|
||
"description": description,
|
||
},
|
||
)
|
||
except _DuplicateIngest as e:
|
||
self._fail({"error": str(e)})
|
||
self.logger.info(f"[{self.name}] ingest duplicate channel={channel!r} error={str(e)!r}")
|
||
return
|
||
except Exception as e:
|
||
self._fail({"error": f"{type(e).__name__}: {e}"})
|
||
self.logger.info(f"[{self.name}] ingest crashed channel={channel!r} error={type(e).__name__}: {e}")
|
||
return
|
||
|
||
self.context.response.success = True
|
||
self.context.response.answer = f"Ingested {outcome['name']} to {outcome['path']}"
|
||
self.context.response.metadata.update(outcome)
|
||
self.logger.info(
|
||
f"[{self.name}] channel={channel} date={outcome['date']} name={outcome['name']} path={outcome['path']}",
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
|
||
def _fail(self, payload: dict) -> None:
|
||
assert self.context is not None
|
||
self.context.response.success = False
|
||
self.context.response.answer = f"Error: {payload.get('error', 'ingest failed')}"
|
||
self.context.response.metadata.update(payload)
|
||
|
||
def _resource_dir_name(self) -> str:
|
||
"""Configured ``resource_dir`` subdir name."""
|
||
return self.config_value("resource_dir")
|
||
|
||
def _vault_dir(self) -> Path:
|
||
vr = getattr(self.file_store, "vault_path", None)
|
||
return Path(vr).resolve() if vr else Path.cwd().resolve()
|
||
|
||
def _land(self, src: Path, date: str, final_name: str, entry_fields: dict) -> dict:
|
||
resource_dir = self._resource_dir_name()
|
||
bucket = self._vault_dir() / resource_dir / date
|
||
bucket.mkdir(parents=True, exist_ok=True)
|
||
meta_path = bucket / "meta.json"
|
||
day_md = bucket / f"{date}.md"
|
||
lock_path = bucket / ".lock"
|
||
|
||
rel_path = f"{resource_dir}/{date}/{final_name}"
|
||
|
||
with _bucket_lock(lock_path):
|
||
existing_entries = _read_meta(meta_path)
|
||
# Collision check spans meta ∪ on-disk listing so a stray file
|
||
# (from a crashed earlier run) and case-insensitive filesystems
|
||
# both surface the conflict rather than getting silently clobbered.
|
||
on_disk = {p.name for p in bucket.iterdir() if p.is_file()}
|
||
existing_names = {Path(e.path).name for e in existing_entries} | on_disk
|
||
existing_names_folded = {name.casefold() for name in existing_names}
|
||
if final_name.casefold() in existing_names_folded:
|
||
raise _DuplicateIngest(
|
||
f"duplicate: {final_name!r} already exists in {resource_dir}/{date}/",
|
||
)
|
||
|
||
dst_path = bucket / final_name
|
||
shutil.copyfile(src, dst_path)
|
||
|
||
# FileFrontMatter has first-class `name` / `description`; everything
|
||
# else (channel / source / received_at / passthrough metadata) rides
|
||
# the extras bag (model_config extra="allow").
|
||
description = entry_fields.pop("description", "")
|
||
entry = FileNode(
|
||
path=rel_path,
|
||
st_mtime=dst_path.stat().st_mtime,
|
||
front_matter=FileFrontMatter(description=description, **entry_fields),
|
||
)
|
||
updated = existing_entries + [entry]
|
||
_atomic_write_text(
|
||
meta_path,
|
||
json.dumps([e.model_dump() for e in updated], ensure_ascii=False, indent=2) + "\n",
|
||
)
|
||
_atomic_write_text(day_md, _assemble_day_md(updated, date))
|
||
|
||
return {
|
||
"date": date,
|
||
"name": final_name,
|
||
"path": rel_path,
|
||
}
|
||
|
||
|
||
class _DuplicateIngest(Exception):
|
||
"""Raised when the derived name already exists in the bucket."""
|
||
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Input validation
|
||
# ----------------------------------------------------------------------
|
||
|
||
|
||
def _prepare_inputs(
|
||
path: str,
|
||
channel: str,
|
||
description: str,
|
||
metadata_raw,
|
||
) -> tuple[dict, str]:
|
||
"""Validate caller args and derive the bucket date / final name.
|
||
|
||
Returns ``(prepared, error)``: on success ``prepared`` has
|
||
``{src, date, received_at, final_name, metadata}`` and ``error`` is
|
||
empty; on failure ``prepared`` is ``{}`` and ``error`` carries the
|
||
first violation in user-facing order (path → channel → description
|
||
→ metadata-shape → file existence → basename → metadata-keys).
|
||
"""
|
||
src = Path(path) if path else None
|
||
metadata, meta_error = _sanitize_metadata(metadata_raw) if isinstance(metadata_raw, dict) else ({}, "")
|
||
error = next(
|
||
(
|
||
msg
|
||
for msg in (
|
||
"path is required" if not path else "",
|
||
_validate_channel(channel),
|
||
"description is required" if not description else "",
|
||
"metadata must be a dict" if not isinstance(metadata_raw, dict) else "",
|
||
f"path not found: {path}" if src is not None and not src.is_file() else "",
|
||
_validate_basename(src.name) if src is not None else "",
|
||
meta_error,
|
||
)
|
||
if msg
|
||
),
|
||
"",
|
||
)
|
||
if error:
|
||
return {}, error
|
||
|
||
assert src is not None # narrowed by the "path is required" check
|
||
now = datetime.datetime.now()
|
||
return (
|
||
{
|
||
"src": src,
|
||
"date": now.strftime("%Y-%m-%d"),
|
||
"received_at": now.isoformat(timespec="seconds"),
|
||
"final_name": f"{channel}__{now.strftime('%H%M%S')}__{src.name}",
|
||
"metadata": metadata,
|
||
},
|
||
"",
|
||
)
|
||
|
||
|
||
def _validate_channel(channel: str) -> str:
|
||
"""Return an error string when ``channel`` is unsafe; empty when OK."""
|
||
if not channel:
|
||
return "channel is required"
|
||
if not _CHANNEL_RE.match(channel):
|
||
return f"channel {channel!r} must be lowercase letters / digits / dashes " f"and start with a letter or digit"
|
||
return ""
|
||
|
||
|
||
def _validate_basename(name: str) -> str:
|
||
"""Return an error string when the source basename is unsafe; empty when OK.
|
||
|
||
The derived filename embeds this string after a ``__`` separator, so we
|
||
only need to block characters that would mangle the filesystem path —
|
||
path separators, dot segments, leading dots. Bookkeeping-name collisions
|
||
(``meta.json`` / ``<date>.md``) are impossible by construction once the
|
||
channel + time prefix is prepended.
|
||
"""
|
||
if not name:
|
||
return "source basename is empty"
|
||
if "/" in name or "\\" in name:
|
||
return f"source basename must not contain path separators: {name!r}"
|
||
if name in {".", ".."}:
|
||
return f"source basename {name!r} is reserved"
|
||
if name.startswith("."):
|
||
return f"source basename may not start with '.': {name!r}"
|
||
return ""
|
||
|
||
|
||
def _sanitize_metadata(raw: dict) -> tuple[dict, str]:
|
||
"""Return ``(cleaned_metadata, error)``.
|
||
|
||
Rejects reserved keys (those the step manages itself) and coerces
|
||
``source`` to a string. All other keys pass through verbatim so
|
||
callers can attach arbitrary tags that land on the entry's
|
||
``front_matter`` extras.
|
||
"""
|
||
for key in _RESERVED_METADATA_KEYS:
|
||
if key in raw:
|
||
return {}, f"metadata key {key!r} is reserved"
|
||
|
||
cleaned = dict(raw)
|
||
if "source" in cleaned:
|
||
src = cleaned["source"]
|
||
cleaned["source"] = src.strip() if isinstance(src, str) else ""
|
||
return cleaned, ""
|
||
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Per-day exclusive lock + atomic write helpers
|
||
# ----------------------------------------------------------------------
|
||
|
||
|
||
class _bucket_lock:
|
||
"""Exclusive ``flock`` on a per-day lock file; serializes meta+md writes."""
|
||
|
||
def __init__(self, lock_path: Path):
|
||
self.lock_path = lock_path
|
||
self._fp = None
|
||
|
||
def __enter__(self):
|
||
self._fp = open(self.lock_path, "w", encoding="utf-8")
|
||
fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX)
|
||
return self
|
||
|
||
def __exit__(self, *exc):
|
||
if self._fp is not None:
|
||
try:
|
||
fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
|
||
finally:
|
||
self._fp.close()
|
||
self._fp = None
|
||
|
||
|
||
def _read_meta(meta_path: Path) -> list[FileNode]:
|
||
"""Read ``meta.json`` as a list of :class:`FileNode` rows; missing or malformed → []."""
|
||
if not meta_path.is_file():
|
||
return []
|
||
try:
|
||
raw = json.loads(meta_path.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError):
|
||
return []
|
||
if not isinstance(raw, list):
|
||
return []
|
||
out: list[FileNode] = []
|
||
for row in raw:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
try:
|
||
out.append(FileNode(**row))
|
||
except Exception:
|
||
continue
|
||
return out
|
||
|
||
|
||
def _atomic_write_text(target: Path, text: str) -> None:
|
||
"""Atomic text write via tempfile + os.replace in the same directory."""
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp_fd, tmp_path = tempfile.mkstemp(prefix=".ingest-", dir=target.parent)
|
||
try:
|
||
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
||
f.write(text)
|
||
os.replace(tmp_path, target)
|
||
except Exception:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Day-view markdown rendering (pure)
|
||
# ----------------------------------------------------------------------
|
||
|
||
|
||
def _assemble_day_md(entries: list[FileNode], date: str) -> str:
|
||
"""Render the day's bucket as a markdown view.
|
||
|
||
Layout::
|
||
|
||
---
|
||
name: <date>
|
||
assets: [<name1>, <name2>, ...]
|
||
---
|
||
|
||
# <date> resources
|
||
|
||
- [[<path>]] — <channel> from `<source>` at <hh:mm> — <description>
|
||
|
||
Provenance lives on each entry's ``front_matter`` (``channel`` /
|
||
``source`` / ``received_at`` as extras, ``description`` as the
|
||
first-class field). ``received_at`` is rendered as ``HH:MM`` when it
|
||
parses as ISO 8601, else dropped silently — the asset list stays
|
||
readable even when upstream channels emit malformed timestamps.
|
||
``source`` is dropped when empty. ``description`` is flattened
|
||
(newlines collapsed to spaces) so the bullet stays one line per
|
||
asset; ``meta.json`` preserves the verbatim multi-line text.
|
||
"""
|
||
names = [Path(e.path).name for e in entries]
|
||
lines: list[str] = [
|
||
"---",
|
||
f"name: {date}",
|
||
f"assets: {json.dumps(names, ensure_ascii=False)}",
|
||
"---",
|
||
"",
|
||
f"# {date} resources",
|
||
"",
|
||
]
|
||
for entry in entries:
|
||
fm = entry.front_matter
|
||
channel = getattr(fm, "channel", "") or ""
|
||
source = getattr(fm, "source", "") or ""
|
||
received_at = getattr(fm, "received_at", "") or ""
|
||
description = fm.description or ""
|
||
|
||
bits: list[str] = [f"- [[{entry.path}]]"]
|
||
provenance = channel
|
||
if source:
|
||
provenance += f" from `{source}`"
|
||
time_part = _hhmm(received_at)
|
||
if time_part:
|
||
provenance += f" at {time_part}"
|
||
bits.append(provenance)
|
||
if description:
|
||
# Flatten so the bullet stays one line per asset; meta.json keeps
|
||
# the verbatim multi-line description for downstream consumers.
|
||
bits.append(" ".join(description.split()))
|
||
lines.append(" — ".join(bits))
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def _hhmm(received_at: str) -> str:
|
||
"""Best-effort HH:MM extraction from an ISO 8601 timestamp."""
|
||
if not received_at:
|
||
return ""
|
||
raw = received_at.replace("Z", "+00:00")
|
||
try:
|
||
return datetime.datetime.fromisoformat(raw).strftime("%H:%M")
|
||
except ValueError:
|
||
return ""
|